claude-code-session-manager 0.35.7 → 0.35.9
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-C4BgkGy0.js → TiptapBody-Wc0rOkr4.js} +1 -1
- package/dist/assets/{index-DgFpE4dn.js → index-B4PkQh71.js} +286 -285
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +3 -1
- package/src/main/__tests__/dod-drain-hook.test.cjs +105 -0
- package/src/main/__tests__/runVerify.test.cjs +33 -9
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +11 -1
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +62 -0
- package/src/main/browserView.cjs +46 -1
- package/src/main/index.cjs +29 -0
- package/src/main/ipcSchemas.cjs +15 -0
- package/src/main/lib/definitionOfDone.cjs +38 -0
- package/src/main/lib/dodDrainHook.cjs +23 -1
- package/src/main/runVerify.cjs +16 -0
- package/src/main/scheduler.cjs +15 -8
- package/src/preload/api.d.ts +20 -1
- package/src/preload/browserViewPreload.cjs +52 -1
- package/src/preload/index.cjs +2 -0
package/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-B4PkQh71.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
13
|
<link rel="stylesheet" crossorigin href="./assets/index-C7NyYuXu.css">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.9",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -58,7 +58,9 @@ Data-driven from 400+ scheduler runs: long hangs (not bad code) are the dominant
|
|
|
58
58
|
- **Verify before done.** Run the acceptance test command once before declaring success. If it's red, fix it or `exit 1` with the failure — never end the run on a failing test (that trips the verifier's `transcript_errors` downgrade).
|
|
59
59
|
- **Fail loud, fail fast.** On any step failure, print one diagnostic line and `exit 1`; don't swallow with `|| true` or spin in a silent retry. A `rateLimited` exit-1 is the scheduler's benign auto-pause (auto-resumes next window) — not a failure to engineer around.
|
|
60
60
|
- **Stay in the AC.** Do not add work past the acceptance checklist ("while we're here" generators/fixtures are the post-AC-overrun incident). Body must be clean UTF-8 — no NUL/control bytes.
|
|
61
|
-
- **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/process-feedback`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (
|
|
61
|
+
- **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/process-feedback`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. This applies just as much to spawning your own review agents and waiting on them: do NOT invoke `/code-review`, `/security-review`, `requesting-code-review`, or any other skill/subagent as a background/async step and then end your turn with something like "I'll wait for the review agents to complete" — a headless run has no next turn, so that line is the run's last output, no verdict sentinel prints, and the job parks in `needs_review` even though the actual work already landed. If a PRD's acceptance criteria call for a second review pass, run it **synchronously, inline, before the finish protocol** — call the reviewer and read its result in the same turn, don't fire-and-wait. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (Incidents: PRD 460 invoked `/develop`, spawned a duplicate PRD 461, and exited 0 with no work. PRD 479 landed its commit correctly but then backgrounded `/code-review --fix` + `/security-review` and called `ScheduleWakeup` to "wait" for them — same class of failure, different entry point.)
|
|
62
|
+
- **A shared-repo `cwd` can be occupied by a concurrent job — check before you touch shared state.** When a PRD's `cwd` is a repo other headless runs may also target (a shared team repo like sigma, not a private single-purpose project), a `git checkout`/`gh pr checkout` can land you in another job's live worktree with its own uncommitted WIP. Before running `git stash`, `git reset`, or any command that discards or hides working-tree state, check `git stash list` and `git status` first, and if you must set aside pre-existing uncommitted changes that aren't yours, **stash with a descriptive message** (`git stash push -m "pre-existing WIP found by PRD <NN>, not mine"`) and **restore it before your run ends** (or, if you can't safely restore because your own commit depends on that worktree state, leave it stashed with the message and say so explicitly in your finish output — never let the run end silently dropping someone else's stash). Never `git stash drop`/`git clean -fd` on state you didn't create. (Incident: PRD 477 stashed a concurrent job's rAF-throttle-revert WIP to get its own checkout, finished, and exited without restoring it — orphaning the other job's uncommitted work in `stash@{0}` with no record of whose it was.)
|
|
63
|
+
- **`gh pr edit --body` can fail on repos with legacy GitHub Projects (classic) boards** — the underlying GraphQL query fetches `repository.pullRequest.projectCards`, a field GitHub is sunsetting, and errors with `GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)` even though the edit itself would otherwise succeed. This is a known `gh` CLI quirk, not a defect in your work. Prefer `gh api -X PATCH repos/<owner>/<repo>/pulls/<n> -f body="$(cat body.md)"` for updating a PR description headlessly — it doesn't touch the deprecated field. If you do use `gh pr edit` and it fails this way, don't leave the bare GraphQL error as the last thing in that step (it reads as an unrecovered error in the final-20%-of-transcript verifier heuristic): immediately retry with the `gh api` form and print one line noting the known-bug fallback, so the recovery is adjacent to the error.
|
|
62
64
|
- **Negative-assertion checks must exit 0 when clean.** A check that verifies the *absence* of something (a `grep` that should find nothing, "no leftover X", `diff` expecting no change) must return exit 0 on the clean case. A bare `grep` exits **1 on no-match** — so the *success* path surfaces as `is_error=true` and the verifier downgrades a perfect run to `needs_review`. Always invert: `if <detector>; then echo "HALT: <what was found>"; exit 1; fi; echo clean`. Never let the no-match/empty path carry the non-zero exit.
|
|
63
65
|
- **Recover or annotate every error — don't strand a Traceback in the transcript.** The verifier downgrades an otherwise-perfect run to `needs_review` when a `Traceback`/`Error` appears with *no visible recovery within ~10 lines* (the `transcript_errors` heuristic — the single most common false-positive on green deliverables). Two executor habits cause it: (1) **throwaway probes that error** — an inline `python -c` with a quoting/f-string slip, a wrong kwarg, a bad path. When a probe errors, immediately re-run the corrected version *or* print one line `# expected/handled: <why>` right after, so recovery is adjacent. Don't move on leaving a bare error as the last thing in that step. Prefer a small temp `.py` file over a fragile multi-quote `python -c` one-liner (inline f-string errors are the top source of stranded tracebacks). (2) See the timeout rule below.
|
|
64
66
|
- **An *expected* bounded-timeout (exit 124) must be annotated, not bare.** `timeout`-capping a genuinely long task you expect to hit the cap (a full-universe ingest, a long scan) is correct — but a bare `Exit code 124` reads as a failure to the verifier. Wrap it so the cap is a success-with-note: `timeout 120 <cmd> || { rc=$?; [ $rc -eq 124 ] && echo "hit time cap — idempotent/partial, rows persist incrementally; OK" || { echo "HALT: <cmd> failed rc=$rc"; exit 1; }; }`. (Distinguish 124 = expected cap from a real non-zero.) For work that legitimately needs longer than a safe cap, run it in the background and poll a bounded number of times rather than capping the foreground command.
|
|
@@ -19,6 +19,7 @@ const os = require('node:os');
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const { runDefinitionOfDoneOnDrain } = require('../lib/dodDrainHook.cjs');
|
|
22
|
+
const { readWatermark } = require('../lib/definitionOfDone.cjs');
|
|
22
23
|
|
|
23
24
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
24
25
|
|
|
@@ -172,6 +173,110 @@ test('paused state → no report written', async () => {
|
|
|
172
173
|
}
|
|
173
174
|
});
|
|
174
175
|
|
|
176
|
+
test('first-ever drain (no watermark file) processes all completed jobs', async () => {
|
|
177
|
+
const tmpDir = makeTmpDir();
|
|
178
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
179
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
180
|
+
const cwd = path.join(tmpDir, 'project');
|
|
181
|
+
fs.mkdirSync(prdsDir);
|
|
182
|
+
fs.mkdirSync(cwd);
|
|
183
|
+
|
|
184
|
+
const jobA = writePrd(prdsDir, '106-old', cwd);
|
|
185
|
+
jobA.finishedAt = '2020-01-01T00:00:00.000Z';
|
|
186
|
+
const jobB = writePrd(prdsDir, '107-new', cwd);
|
|
187
|
+
jobB.finishedAt = '2026-07-11T00:00:00.000Z';
|
|
188
|
+
const state = { paused: null, jobs: [jobA, jobB] };
|
|
189
|
+
const cancelToken = { cancelled: false };
|
|
190
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
191
|
+
delete process.env.SM_DOD_DISABLE;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
assert.strictEqual(readWatermark(runsDir), null, 'no watermark should exist yet');
|
|
195
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
196
|
+
assert.strictEqual(countReports(runsDir), 1);
|
|
197
|
+
|
|
198
|
+
const reportsRoot = fs.readdirSync(runsDir, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
199
|
+
const reportFile = reportsRoot
|
|
200
|
+
.map(e => path.join(runsDir, e.name))
|
|
201
|
+
.flatMap(dir => fs.readdirSync(dir).filter(f => f.startsWith('definition-of-done-')).map(f => path.join(dir, f)))[0];
|
|
202
|
+
const contents = fs.readFileSync(reportFile, 'utf8');
|
|
203
|
+
assert.match(contents, /106-old/, 'first-ever drain must include old job too');
|
|
204
|
+
assert.match(contents, /107-new/);
|
|
205
|
+
} finally {
|
|
206
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
207
|
+
rmdir(tmpDir);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('subsequent drain with a persisted watermark only reverifies newer jobs', async () => {
|
|
212
|
+
const tmpDir = makeTmpDir();
|
|
213
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
214
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
215
|
+
const cwd = path.join(tmpDir, 'project');
|
|
216
|
+
fs.mkdirSync(prdsDir);
|
|
217
|
+
fs.mkdirSync(cwd);
|
|
218
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
219
|
+
fs.writeFileSync(
|
|
220
|
+
path.join(runsDir, '.dod-watermark.json'),
|
|
221
|
+
JSON.stringify({ lastFinishedAt: '2026-01-01T00:00:00.000Z' })
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const jobOld = writePrd(prdsDir, '108-old', cwd);
|
|
225
|
+
jobOld.finishedAt = '2025-01-01T00:00:00.000Z';
|
|
226
|
+
const jobNew = writePrd(prdsDir, '109-new', cwd);
|
|
227
|
+
jobNew.finishedAt = '2026-07-11T00:00:00.000Z';
|
|
228
|
+
const state = { paused: null, jobs: [jobOld, jobNew] };
|
|
229
|
+
const cancelToken = { cancelled: false };
|
|
230
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
231
|
+
delete process.env.SM_DOD_DISABLE;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
235
|
+
assert.strictEqual(countReports(runsDir), 1);
|
|
236
|
+
|
|
237
|
+
const reportsRoot = fs.readdirSync(runsDir, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
238
|
+
const reportFile = reportsRoot
|
|
239
|
+
.map(e => path.join(runsDir, e.name))
|
|
240
|
+
.flatMap(dir => fs.readdirSync(dir).filter(f => f.startsWith('definition-of-done-')).map(f => path.join(dir, f)))[0];
|
|
241
|
+
const contents = fs.readFileSync(reportFile, 'utf8');
|
|
242
|
+
assert.doesNotMatch(contents, /108-old/, 'jobs finished before the watermark must be excluded');
|
|
243
|
+
assert.match(contents, /109-new/, 'jobs finished after the watermark must be included');
|
|
244
|
+
} finally {
|
|
245
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
246
|
+
rmdir(tmpDir);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('watermark advances after a report so a repeat drain over the same jobs is a no-op', async () => {
|
|
251
|
+
const tmpDir = makeTmpDir();
|
|
252
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
253
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
254
|
+
const cwd = path.join(tmpDir, 'project');
|
|
255
|
+
fs.mkdirSync(prdsDir);
|
|
256
|
+
fs.mkdirSync(cwd);
|
|
257
|
+
|
|
258
|
+
const job = writePrd(prdsDir, '110-test', cwd);
|
|
259
|
+
job.finishedAt = '2026-07-11T00:00:00.000Z';
|
|
260
|
+
const state = { paused: null, jobs: [job] };
|
|
261
|
+
const cancelToken = { cancelled: false };
|
|
262
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
263
|
+
delete process.env.SM_DOD_DISABLE;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
267
|
+
assert.strictEqual(countReports(runsDir), 1, 'first drain writes one report');
|
|
268
|
+
assert.strictEqual(readWatermark(runsDir), job.finishedAt, 'watermark must advance to the covered job\'s finishedAt');
|
|
269
|
+
|
|
270
|
+
// Second drain with a fresh job list containing the same already-covered job.
|
|
271
|
+
const state2 = { paused: null, jobs: [{ ...job }] };
|
|
272
|
+
await runDefinitionOfDoneOnDrain(state2, { cancelToken, prdsDir, runsDir });
|
|
273
|
+
assert.strictEqual(countReports(runsDir), 1, 'repeat drain over the same (now-old) job must be a no-op');
|
|
274
|
+
} finally {
|
|
275
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
276
|
+
rmdir(tmpDir);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
175
280
|
test('cancelToken.cancelled=true → no report written', async () => {
|
|
176
281
|
const tmpDir = makeTmpDir();
|
|
177
282
|
const prdsDir = path.join(tmpDir, 'prds');
|
|
@@ -297,6 +297,7 @@ test('Truly clean run (no error markers) → clean/null', async () => {
|
|
|
297
297
|
prdPath,
|
|
298
298
|
queueEntry: { slug, status: 'running' },
|
|
299
299
|
allJobs: [],
|
|
300
|
+
committedDuringRun: true,
|
|
300
301
|
});
|
|
301
302
|
|
|
302
303
|
assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
@@ -379,6 +380,7 @@ test('FAIL recovered within 30 events → clean', async () => {
|
|
|
379
380
|
prdPath,
|
|
380
381
|
queueEntry: { slug, status: 'running' },
|
|
381
382
|
allJobs: [],
|
|
383
|
+
committedDuringRun: true,
|
|
382
384
|
});
|
|
383
385
|
|
|
384
386
|
assert.equal(verdict.verdict, 'clean', `self-recovery should yield clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
@@ -430,7 +432,7 @@ test('feedback 01: reviewer prose mentioning ImportError mid-sentence → clean'
|
|
|
430
432
|
].join('\n');
|
|
431
433
|
writeLog(tmp, slug, bashRunEvents(prose));
|
|
432
434
|
const prdPath = writePrd(tmp, slug, '# Hardening');
|
|
433
|
-
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
|
|
435
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
434
436
|
assert.equal(verdict.verdict, 'clean', `prose mention must not flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
435
437
|
} finally { rmdir(tmp); }
|
|
436
438
|
});
|
|
@@ -477,7 +479,7 @@ test('addendum: Traceback→ModuleNotFoundError in a SUCCEEDED run → clean (an
|
|
|
477
479
|
].join('\n');
|
|
478
480
|
writeLog(tmp, slug, bashRunEvents(probe)); // resultSubtype defaults to success
|
|
479
481
|
const prdPath = writePrd(tmp, slug, '# Shared lib');
|
|
480
|
-
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
|
|
482
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
481
483
|
assert.equal(verdict.verdict, 'clean', `recovered env probe must not downgrade, got ${verdict.verdict}: ${verdict.reason}`);
|
|
482
484
|
assert.equal(verdict.downgradeTo, null);
|
|
483
485
|
assert.ok(Array.isArray(verdict.annotations) && verdict.annotations.length === 1, 'should record one annotation');
|
|
@@ -491,7 +493,7 @@ test('addendum: bare Import/ModuleNotFound probe (no traceback) in SUCCEEDED run
|
|
|
491
493
|
const slug = '26-self-parser-tests';
|
|
492
494
|
writeLog(tmp, slug, bashRunEvents("ModuleNotFoundError: No module named 'conftest'"));
|
|
493
495
|
const prdPath = writePrd(tmp, slug, '# Parser tests');
|
|
494
|
-
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
|
|
496
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
495
497
|
assert.equal(verdict.verdict, 'clean', `got ${verdict.verdict}: ${verdict.reason}`);
|
|
496
498
|
assert.ok(Array.isArray(verdict.annotations) && verdict.annotations.length === 1);
|
|
497
499
|
} finally { rmdir(tmp); }
|
|
@@ -527,7 +529,7 @@ test('feedback 01: error-shaped text inside a Task (subagent) result → clean',
|
|
|
527
529
|
].join('\n');
|
|
528
530
|
writeLog(tmp, slug, bashRunEvents(reviewFinding, { toolName: 'Task' }));
|
|
529
531
|
const prdPath = writePrd(tmp, slug, '# Review-heavy PRD');
|
|
530
|
-
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
|
|
532
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
531
533
|
assert.equal(verdict.verdict, 'clean', `Task results must be exempt from pattern scan, got ${verdict.verdict}: ${verdict.reason}`);
|
|
532
534
|
} finally { rmdir(tmp); }
|
|
533
535
|
});
|
|
@@ -543,7 +545,7 @@ test('feedback 01: quoted "Traceback..." line (leading quote) → clean', async
|
|
|
543
545
|
].join('\n');
|
|
544
546
|
writeLog(tmp, slug, bashRunEvents(prose));
|
|
545
547
|
const prdPath = writePrd(tmp, slug, '# Docs PRD');
|
|
546
|
-
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
|
|
548
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
547
549
|
assert.equal(verdict.verdict, 'clean', `quoted traceback prose must not flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
548
550
|
} finally { rmdir(tmp); }
|
|
549
551
|
});
|
|
@@ -572,7 +574,7 @@ test('harness tool error (<tool_use_error>) in final 20% → clean', async () =>
|
|
|
572
574
|
|
|
573
575
|
writeLog(tmp, slug, events);
|
|
574
576
|
const prdPath = writePrd(tmp, slug, '# Correctness batch');
|
|
575
|
-
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
|
|
577
|
+
const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
|
|
576
578
|
assert.equal(verdict.verdict, 'clean', `harness tool error must not flag, got ${verdict.verdict}: ${verdict.reason}`);
|
|
577
579
|
} finally { rmdir(tmp); }
|
|
578
580
|
});
|
|
@@ -859,7 +861,7 @@ test('no_verdict_sentinel guard: same run but committedDuringRun:true → stays
|
|
|
859
861
|
}
|
|
860
862
|
});
|
|
861
863
|
|
|
862
|
-
test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS sentinel → stays clean', async () => {
|
|
864
|
+
test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS sentinel + a commit → stays clean', async () => {
|
|
863
865
|
const tmp = makeTmpDir();
|
|
864
866
|
try {
|
|
865
867
|
const slug = '406-browser-capture-panel-ui-sentinel';
|
|
@@ -870,15 +872,37 @@ test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS senti
|
|
|
870
872
|
prdPath,
|
|
871
873
|
queueEntry: { slug, status: 'running' },
|
|
872
874
|
allJobs: [],
|
|
873
|
-
committedDuringRun:
|
|
875
|
+
committedDuringRun: true,
|
|
874
876
|
});
|
|
875
|
-
assert.equal(verdict.verdict, 'clean', `PASS sentinel should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
877
|
+
assert.equal(verdict.verdict, 'clean', `PASS sentinel + commit should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
876
878
|
assert.equal(verdict.downgradeTo, null);
|
|
877
879
|
} finally {
|
|
878
880
|
rmdir(tmp);
|
|
879
881
|
}
|
|
880
882
|
});
|
|
881
883
|
|
|
884
|
+
// ─── pass_no_commit: PASS sentinel with no commit is not "clean" ───────────
|
|
885
|
+
|
|
886
|
+
test('pass_no_commit: SCHEDULER_VERDICT: PASS but no commit landed → needs_review', async () => {
|
|
887
|
+
const tmp = makeTmpDir();
|
|
888
|
+
try {
|
|
889
|
+
const slug = '511-recorder-mouse-drag-panel-export';
|
|
890
|
+
writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
|
|
891
|
+
const prdPath = writePrd(tmp, slug, '# Recorder mouse drag panel export');
|
|
892
|
+
const verdict = await verifyRun({
|
|
893
|
+
runDir: tmp,
|
|
894
|
+
prdPath,
|
|
895
|
+
queueEntry: { slug, status: 'running' },
|
|
896
|
+
allJobs: [],
|
|
897
|
+
committedDuringRun: false,
|
|
898
|
+
});
|
|
899
|
+
assert.equal(verdict.verdict, 'pass_no_commit', `expected pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
|
|
900
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
901
|
+
} finally {
|
|
902
|
+
rmdir(tmp);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
882
906
|
// (e) halt + sentinel PASS → still halt (override must not apply to halt)
|
|
883
907
|
test('halt result + sentinel PASS + committedDuringRun:true → still halt', async () => {
|
|
884
908
|
const tmp = makeTmpDir();
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const { test } = require('node:test');
|
|
10
10
|
const assert = require('node:assert/strict');
|
|
11
|
-
const { selectAutoFixTargets, isUnresolvableNeedsReview } = require('../scheduler.cjs');
|
|
11
|
+
const { selectAutoFixTargets, isUnresolvableNeedsReview, isRescanCandidate } = require('../scheduler.cjs');
|
|
12
12
|
|
|
13
13
|
const noSiblingOnDisk = () => false;
|
|
14
14
|
const noRunDir = () => null;
|
|
@@ -140,4 +140,14 @@ test('defaults parallelGroup to 99 when absent', () => {
|
|
|
140
140
|
assert.strictEqual(seen[0], '99-fix-my-feature');
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
test('isRescanCandidate: needs_review + runId + no_verdict_sentinel → true (RESCANNABLE_VERDICTS includes it)', () => {
|
|
144
|
+
const job = makeJob({ verifierVerdict: 'no_verdict_sentinel' });
|
|
145
|
+
assert.strictEqual(isRescanCandidate(job), true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('isRescanCandidate: needs_review + runId + uncommitted_changes → false (git commit-guard verdict stays excluded)', () => {
|
|
149
|
+
const job = makeJob({ verifierVerdict: 'uncommitted_changes' });
|
|
150
|
+
assert.strictEqual(isRescanCandidate(job), false);
|
|
151
|
+
});
|
|
152
|
+
|
|
143
153
|
console.log('scheduler-autofix-select tests: PASS');
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-committed-in-window.test.cjs — unit tests for committedInWindow.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-committed-in-window.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { execFileSync } = require('node:child_process');
|
|
13
|
+
const { committedInWindow } = require('../scheduler.cjs');
|
|
14
|
+
|
|
15
|
+
function git(cwd, args) {
|
|
16
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeRepo() {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-'));
|
|
21
|
+
git(dir, ['init', '-q']);
|
|
22
|
+
git(dir, ['config', 'user.email', 'test@example.com']);
|
|
23
|
+
git(dir, ['config', 'user.name', 'Test']);
|
|
24
|
+
fs.writeFileSync(path.join(dir, 'base.txt'), 'base\n');
|
|
25
|
+
git(dir, ['add', '.']);
|
|
26
|
+
git(dir, ['commit', '-q', '-m', 'base']);
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('committedInWindow returns true for a commit landed on a non-checked-out branch', async () => {
|
|
31
|
+
const dir = makeRepo();
|
|
32
|
+
try {
|
|
33
|
+
git(dir, ['checkout', '-q', '-b', 'work']);
|
|
34
|
+
|
|
35
|
+
const startedAt = new Date().toISOString();
|
|
36
|
+
fs.writeFileSync(path.join(dir, 'feature.txt'), 'feature\n');
|
|
37
|
+
git(dir, ['add', '.']);
|
|
38
|
+
git(dir, ['commit', '-q', '-m', 'feature commit']);
|
|
39
|
+
const finishedAt = new Date(Date.now() + 1000).toISOString();
|
|
40
|
+
|
|
41
|
+
// Simulate the run leaving a different branch checked out at exit.
|
|
42
|
+
git(dir, ['checkout', '-q', '-b', 'other-branch-at-exit']);
|
|
43
|
+
|
|
44
|
+
const result = await committedInWindow(dir, startedAt, finishedAt);
|
|
45
|
+
expect(result).toBe(true);
|
|
46
|
+
} finally {
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('committedInWindow returns false when the window covers no commit', async () => {
|
|
52
|
+
const dir = makeRepo();
|
|
53
|
+
try {
|
|
54
|
+
const farFutureStart = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
|
55
|
+
const farFutureEnd = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000).toISOString();
|
|
56
|
+
|
|
57
|
+
const result = await committedInWindow(dir, farFutureStart, farFutureEnd);
|
|
58
|
+
expect(result).toBe(false);
|
|
59
|
+
} finally {
|
|
60
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
package/src/main/browserView.cjs
CHANGED
|
@@ -344,7 +344,7 @@ function handleRecordEvent(event, payload) {
|
|
|
344
344
|
const viewId = contentsIdToViewId.get(event.sender.id);
|
|
345
345
|
if (!viewId || !recordingViewIds.has(viewId)) return;
|
|
346
346
|
const verb = payload && payload.verb;
|
|
347
|
-
if (verb !== 'click' && verb !== 'type') return;
|
|
347
|
+
if (verb !== 'click' && verb !== 'type' && verb !== 'drag') return;
|
|
348
348
|
const target = typeof payload.target === 'string' ? payload.target.slice(0, 300) : '';
|
|
349
349
|
const step = { verb, target };
|
|
350
350
|
if (verb === 'type') {
|
|
@@ -353,6 +353,19 @@ function handleRecordEvent(event, payload) {
|
|
|
353
353
|
step.variableSuggestion = payload.variableSuggestion.slice(0, 64);
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
|
+
if (verb === 'click') {
|
|
357
|
+
const x = Number(payload.x);
|
|
358
|
+
const y = Number(payload.y);
|
|
359
|
+
if (Number.isFinite(x)) step.x = x;
|
|
360
|
+
if (Number.isFinite(y)) step.y = y;
|
|
361
|
+
}
|
|
362
|
+
if (verb === 'drag') {
|
|
363
|
+
if (typeof payload.endTarget === 'string') step.endTarget = payload.endTarget.slice(0, 300);
|
|
364
|
+
for (const key of ['x', 'y', 'endX', 'endY']) {
|
|
365
|
+
const n = Number(payload[key]);
|
|
366
|
+
if (Number.isFinite(n)) step[key] = n;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
356
369
|
emitRecordStep(viewId, step);
|
|
357
370
|
}
|
|
358
371
|
|
|
@@ -371,7 +384,16 @@ function withTimeout(promise, ms, label) {
|
|
|
371
384
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
372
385
|
}
|
|
373
386
|
|
|
387
|
+
async function replayPositionedClick(wc, x, y) {
|
|
388
|
+
wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
|
389
|
+
wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
|
|
390
|
+
}
|
|
391
|
+
|
|
374
392
|
async function replayClickOrType(wc, step, values) {
|
|
393
|
+
if (step.verb === 'click' && Number.isFinite(step.x) && Number.isFinite(step.y)) {
|
|
394
|
+
await replayPositionedClick(wc, step.x, step.y);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
375
397
|
const script =
|
|
376
398
|
step.verb === 'type'
|
|
377
399
|
? `(() => {
|
|
@@ -429,6 +451,25 @@ async function replayWaitFor(wc, step) {
|
|
|
429
451
|
}
|
|
430
452
|
}
|
|
431
453
|
|
|
454
|
+
const REPLAY_DRAG_STEPS = 3;
|
|
455
|
+
|
|
456
|
+
async function replayDrag(wc, step) {
|
|
457
|
+
const { x, y, endX, endY } = step;
|
|
458
|
+
if (![x, y, endX, endY].every((n) => Number.isFinite(n))) {
|
|
459
|
+
throw new Error('drag step missing coordinates');
|
|
460
|
+
}
|
|
461
|
+
wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
|
462
|
+
for (let i = 1; i <= REPLAY_DRAG_STEPS; i += 1) {
|
|
463
|
+
const t = i / REPLAY_DRAG_STEPS;
|
|
464
|
+
wc.sendInputEvent({
|
|
465
|
+
type: 'mouseMove',
|
|
466
|
+
x: Math.round(x + (endX - x) * t),
|
|
467
|
+
y: Math.round(y + (endY - y) * t),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
wc.sendInputEvent({ type: 'mouseUp', x: endX, y: endY, button: 'left', clickCount: 1 });
|
|
471
|
+
}
|
|
472
|
+
|
|
432
473
|
async function replayStep(view, step, values) {
|
|
433
474
|
const wc = view.webContents;
|
|
434
475
|
if (step.verb === 'navigate') {
|
|
@@ -445,6 +486,10 @@ async function replayStep(view, step, values) {
|
|
|
445
486
|
await replayWaitFor(wc, step);
|
|
446
487
|
return;
|
|
447
488
|
}
|
|
489
|
+
if (step.verb === 'drag') {
|
|
490
|
+
await withTimeout(replayDrag(wc, step), REPLAY_STEP_TIMEOUT_MS, 'drag');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
448
493
|
throw new Error(`unsupported step verb: ${step.verb}`);
|
|
449
494
|
}
|
|
450
495
|
|
package/src/main/index.cjs
CHANGED
|
@@ -434,6 +434,25 @@ ipcMain.handle('app:pick-directory', async () => {
|
|
|
434
434
|
|
|
435
435
|
ipcMain.on('app:reboot-app', () => rebootApp());
|
|
436
436
|
|
|
437
|
+
// Recorder export "Save to file…" (PRD 412) — native Save As dialog, written
|
|
438
|
+
// directly since the path is user-chosen, not renderer input.
|
|
439
|
+
ipcMain.handle('browser:save-recording', validated(schemas.browserSaveRecording, async ({ defaultName, text }) => {
|
|
440
|
+
try {
|
|
441
|
+
const result = await dialog.showSaveDialog(mainWindow, {
|
|
442
|
+
defaultPath: defaultName,
|
|
443
|
+
filters: [
|
|
444
|
+
{ name: 'Markdown', extensions: ['md'] },
|
|
445
|
+
{ name: 'All Files', extensions: ['*'] },
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
|
|
449
|
+
await fsp.writeFile(result.filePath, text, 'utf8');
|
|
450
|
+
return { ok: true, path: result.filePath };
|
|
451
|
+
} catch (e) {
|
|
452
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
453
|
+
}
|
|
454
|
+
}));
|
|
455
|
+
|
|
437
456
|
// Image paste — Ctrl+V in the Terminal pane. Reads the OS clipboard via
|
|
438
457
|
// Electron's native API (renderer's navigator.clipboard.read() doesn't expose
|
|
439
458
|
// raw image MIME types under contextIsolation), saves the bitmap to a temp
|
|
@@ -469,6 +488,16 @@ ipcMain.handle('clipboard:paste-text', async () => {
|
|
|
469
488
|
}
|
|
470
489
|
});
|
|
471
490
|
|
|
491
|
+
// Recorder export "Copy to clipboard" (PRD 412) — write side.
|
|
492
|
+
ipcMain.handle('clipboard:write-text', validated(schemas.clipboardWriteText, ({ text }) => {
|
|
493
|
+
try {
|
|
494
|
+
clipboard.writeText(text);
|
|
495
|
+
return { ok: true };
|
|
496
|
+
} catch (e) {
|
|
497
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
498
|
+
}
|
|
499
|
+
}));
|
|
500
|
+
|
|
472
501
|
// PRD 407 Capture panel — write side of paste-image's read. Writes a
|
|
473
502
|
// screenshot capture to the OS clipboard as an image.
|
|
474
503
|
ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -96,6 +96,19 @@ const browserCopyImage = z.object({
|
|
|
96
96
|
dataUrl: z.string().min(1).max(50_000_000),
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
// Recorder export (PRD 412): write arbitrary recorded-flow text to the OS
|
|
100
|
+
// clipboard, separate from the image-only browserCopyImage above.
|
|
101
|
+
const clipboardWriteText = z.object({
|
|
102
|
+
text: z.string().max(1_000_000),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Recorder export (PRD 412): native "Save As" dialog write, bypassing the
|
|
106
|
+
// config.cjs write-boundary since the path is user-chosen via OS dialog.
|
|
107
|
+
const browserSaveRecording = z.object({
|
|
108
|
+
defaultName: z.string().min(1).max(255),
|
|
109
|
+
text: z.string().max(1_000_000),
|
|
110
|
+
});
|
|
111
|
+
|
|
99
112
|
// PRD 407: binary-safe atomic write (browser:save-binary) for screenshot
|
|
100
113
|
// captures — config:write-text is utf8-only.
|
|
101
114
|
const browserSaveBinary = z.object({
|
|
@@ -621,6 +634,8 @@ module.exports = {
|
|
|
621
634
|
browserCaptureSelection,
|
|
622
635
|
browserCopyImage,
|
|
623
636
|
browserSaveBinary,
|
|
637
|
+
clipboardWriteText,
|
|
638
|
+
browserSaveRecording,
|
|
624
639
|
browserReplay,
|
|
625
640
|
browserSetZoom,
|
|
626
641
|
browserFind,
|
|
@@ -533,6 +533,42 @@ function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
|
|
|
533
533
|
return reportPath;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
const WATERMARK_FILENAME = '.dod-watermark.json';
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Read the persisted "last finished" watermark used to bound which completed
|
|
540
|
+
* jobs get reverified on each drain. Never throws — a missing or unparseable
|
|
541
|
+
* sidecar means "beginning of time" (process everything), matching the
|
|
542
|
+
* pre-watermark unbounded behavior on first-ever drain.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
|
|
545
|
+
* @returns {string|null} ISO8601 timestamp, or null if unset/unreadable.
|
|
546
|
+
*/
|
|
547
|
+
function readWatermark(runsDir = RUNS_DIR) {
|
|
548
|
+
try {
|
|
549
|
+
const raw = fs.readFileSync(path.join(runsDir, WATERMARK_FILENAME), 'utf8');
|
|
550
|
+
const parsed = JSON.parse(raw);
|
|
551
|
+
return typeof parsed.lastFinishedAt === 'string' ? parsed.lastFinishedAt : null;
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Persist the "last finished" watermark so the next drain only reverifies
|
|
559
|
+
* jobs that completed after it.
|
|
560
|
+
*
|
|
561
|
+
* @param {string} lastFinishedAt ISO8601 timestamp.
|
|
562
|
+
* @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
|
|
563
|
+
*/
|
|
564
|
+
function writeWatermark(lastFinishedAt, runsDir = RUNS_DIR) {
|
|
565
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
566
|
+
_writeFileAtomic(
|
|
567
|
+
path.join(runsDir, WATERMARK_FILENAME),
|
|
568
|
+
JSON.stringify({ lastFinishedAt }, null, 2)
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
536
572
|
module.exports = {
|
|
537
573
|
batchKey,
|
|
538
574
|
reportPathFor,
|
|
@@ -542,4 +578,6 @@ module.exports = {
|
|
|
542
578
|
reverifyBatch,
|
|
543
579
|
flagRiskySurfaces,
|
|
544
580
|
writeReport,
|
|
581
|
+
readWatermark,
|
|
582
|
+
writeWatermark,
|
|
545
583
|
};
|
|
@@ -21,6 +21,8 @@ const {
|
|
|
21
21
|
reverifyBatch,
|
|
22
22
|
flagRiskySurfaces,
|
|
23
23
|
writeReport,
|
|
24
|
+
readWatermark,
|
|
25
|
+
writeWatermark,
|
|
24
26
|
} = require('./definitionOfDone.cjs');
|
|
25
27
|
|
|
26
28
|
// Track keys whose DoD pass is currently in-flight.
|
|
@@ -58,7 +60,19 @@ async function runDefinitionOfDoneOnDrain(state, opts = {}) {
|
|
|
58
60
|
if (state.paused) return;
|
|
59
61
|
if (cancelToken.cancelled) return;
|
|
60
62
|
|
|
61
|
-
const
|
|
63
|
+
const allCompleted = (state.jobs || []).filter(j => j.status === 'completed');
|
|
64
|
+
if (allCompleted.length === 0) return;
|
|
65
|
+
|
|
66
|
+
// Watermark: only reverify jobs that finished after the last DoD report.
|
|
67
|
+
// Jobs missing finishedAt are treated as older than any real watermark
|
|
68
|
+
// (excluded once a watermark exists; included on the first-ever drain when
|
|
69
|
+
// watermark is null, preserving back-compat with the unbounded behavior).
|
|
70
|
+
const wmIso = readWatermark(runsDir); // null on first-ever drain
|
|
71
|
+
const wm = wmIso ? new Date(wmIso).getTime() : 0; // 0 = beginning of time
|
|
72
|
+
const completedJobs = allCompleted.filter(j => {
|
|
73
|
+
const t = j.finishedAt ? new Date(j.finishedAt).getTime() : NaN;
|
|
74
|
+
return wm === 0 ? true : (Number.isFinite(t) && t > wm);
|
|
75
|
+
});
|
|
62
76
|
if (completedJobs.length === 0) return;
|
|
63
77
|
|
|
64
78
|
const key = batchKey(completedJobs);
|
|
@@ -73,6 +87,14 @@ async function runDefinitionOfDoneOnDrain(state, opts = {}) {
|
|
|
73
87
|
if (cancelToken.cancelled) return;
|
|
74
88
|
const riskFlags = flagRiskySurfaces(completedJobs, { prdsDir });
|
|
75
89
|
writeReport(key, { acResults, riskFlags, runsDir });
|
|
90
|
+
|
|
91
|
+
// Advance watermark to the newest finishedAt we just covered, so the next
|
|
92
|
+
// drain excludes this batch. Guard against jobs with no/invalid finishedAt.
|
|
93
|
+
const maxFinished = completedJobs
|
|
94
|
+
.map(j => (j.finishedAt ? new Date(j.finishedAt).getTime() : NaN))
|
|
95
|
+
.filter(Number.isFinite)
|
|
96
|
+
.reduce((a, b) => Math.max(a, b), wm);
|
|
97
|
+
if (maxFinished > wm) writeWatermark(new Date(maxFinished).toISOString(), runsDir);
|
|
76
98
|
} finally {
|
|
77
99
|
_inFlight.delete(key);
|
|
78
100
|
}
|
package/src/main/runVerify.cjs
CHANGED
|
@@ -628,6 +628,22 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
|
|
|
628
628
|
});
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
// A truthful-looking PASS sentinel with no commit is still not "clean":
|
|
632
|
+
// the finish protocol requires the commit to land before printing PASS
|
|
633
|
+
// (see the module's finish-protocol docs), so a PASS with no commit means
|
|
634
|
+
// the run's own claim of success is unsubstantiated — route it to
|
|
635
|
+
// needs_review so the auto-fix pipeline can investigate rather than
|
|
636
|
+
// silently accepting a bare sentinel as proof of work done. Mutually
|
|
637
|
+
// exclusive with the no_verdict_sentinel case above (sentinel === null
|
|
638
|
+
// vs sentinel === 'pass'), kept as a separate sibling check for clarity.
|
|
639
|
+
if (sentinel === 'pass' && !committedDuringRun) {
|
|
640
|
+
issues.push({
|
|
641
|
+
verdict: 'pass_no_commit',
|
|
642
|
+
reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
|
|
643
|
+
priority: 1,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
631
647
|
if (issues.length === 0) {
|
|
632
648
|
const reason = annotations.length
|
|
633
649
|
? `no blocking issues (${annotations.length} annotation(s): ${annotations.map((a) => a.reason).join('; ')})`
|