Commit 5de52cb
fix(mt#4998): Give a sweeper-initiated review the same domain context a webhook one gets
## Summary
The missed-review sweeper called `runReview` with only `{ db }`, while the webhook path passed five domain dependencies besides. Every one of those is `?: T | null` and degrades **quietly** by design, so every sweeper-initiated review posted `Tier: unknown` with an empty `specVerification` array and otherwise looked completely normal — a strictly weaker review than the one a webhook would have produced for the same commit, with nothing in the output saying so.
Observed in production on PR #3633 @ `5c56ffc22` (2026-09-04): a sweeper retrigger 79 seconds after an APPROVE posted CHANGES_REQUESTED with `specVerification: []`, `Tier: unknown`, and a blocking finding that was falsifiable in one typecheck.
**Scope note.** The *duplicate-review* half of that incident — the in-flight marker's 300s TTL expiring mid-review — belongs to mt#4993 and is deliberately untouched here. This defect is orthogonal and **survives that fix**: once the marker stops the sweeper duplicating, its *legitimate* retriggers (the missed-review case the sweeper exists for) would still post context-degraded reviews.
## Key Changes
- **`domain-container.ts`** — new `buildReviewDomainDeps(domainServices)` plus a `ReviewDomainDeps` type, with a table documenting exactly how each missing dep degrades a review.
- **All three `runReview` entry points now use it.** Two (webhook, boot recovery) assembled the identical five-field list by hand; the third omitted it entirely. A hand-assembled dep list is precisely the shape that admits this defect — the omission is invisible at the call site *and* invisible in the output. One builder means a fourth entry point cannot repeat it.
- **`startSweeper`'s 4th parameter widened** from `container` to the whole `DomainServices`. The sweeper needs more than the container, and the container was already being handed to it — it was just only used for the Ask emitter.
- **New `sweeper.retrigger_degraded_context` warn event** naming the missing deps, and (R1) any dep that is present but capability-degraded. This is what separates *"the PR has no bound task"* (correct — an empty `specVerification`, pinned by mt#2153 AT2) from *"the review had no `taskService`"* (the defect). Without it the two are indistinguishable from outside.
- **New `services/reviewer/scripts/verify-review-domain-deps.ts`** — the §7a artifact, verifying the real-wired binding rather than the seam.
This does **not** contradict `runSweep`'s deliberate use of `extractTierFromPRBody` over `resolveTier`. That decision governs the SCAN phase, which touches every open PR and would pay 1–3s each for an MCP round-trip. This is the REVIEW phase for a single PR the scan already selected — bounded by `SWEEP_CONCURRENCY` (1) and by how many reviews are actually missing (typically 0–2), against a review that itself runs for minutes. Different questions, different budgets; the docblock now says so.
## Response to review R1 (5119342686)
**BLOCKING — `domain-container.ts:151`, "`sessionLookup` bound to `sessionProvider` … will break silently at compile-time only" — VERIFIED FALSE POSITIVE. No code changed.**
The claim is decidable by a checker already in the pipeline and already green (mem#1268), so I simulated the exact divergence it describes rather than arguing it: added a required method to `SessionLookup` and re-ran typecheck scoped to `services/reviewer`.
```
src/domain-container.ts:184:5 - error TS2741: Property 'divergenceProbe__temporary'
is missing in type 'SessionProviderInterface' but required in type 'SessionLookup'.
```
The error lands on the cited line. Divergence breaks **loudly, at compile time** — the opposite of the finding's stated mechanism. Two further points, each checked rather than asserted: `SessionLookup` is a one-method interface (`getSession`), and `short-id-fetch.ts:71-72` documents `sessionProvider` as its intended source ("already-injected `sessionProvider` (`domain-container.ts`), so no new production wiring is needed"). The binding is mt#3964's design; this PR **moved** that line out of `server.ts`, it did not introduce it. The probe was reverted; typecheck is green again.
**NON-BLOCKING 1 — `sweeper.ts:392`, degraded-context does not distinguish capability-degraded providers — ADOPTED, and it was a real hole.** A `persistenceProvider` that is present but reports no SQL capability yields `Tier: unknown` exactly as a null one does, and the null check could not see it. My own §7a script already asserted `capabilities.sql`; the runtime warning now does too, in a separate `capabilityDegradedDeps` field — the remedy differs (null = wiring bug, no-SQL = provider/config choice), so folding them into one list would lose that. New test covers it.
**NON-BLOCKING 2 — script not wired into CI, bit-rot risk — ADDRESSED, not by CI.** The script needs a live domain container and a real DB, which this repo's CI does not provision; that is why the other ~15 `smoke-*`/`verify-*` scripts under `services/reviewer/scripts/` are env-gated and run on demand. A CI job that always skips would be worse than none, because a check that cannot fail reads as coverage (mem#704). Instead it now has a **named consumer**: mt#5005 AT4 re-runs it as part of settling SC2, and states why it is the cheapest discriminator if that observation comes back negative.
**NON-BLOCKING 3 — test asserts on an internal log event — declined, with reasoning.** The log event is not an internal here; it *is* the deliverable. SC3 asks that a context-degraded review be distinguishable from one whose PR genuinely has no bound task, and the structured event is the mechanism chosen for that. `retriggerViaRunReview` is invoked fire-and-forget inside `batch.map(...)`, so a return value would have no consumer — adding one to make the test prettier would be a seam with nothing on the other end. Asserting on the observable the requirement names is the designed check, not a workaround.
## Testing
Execution evidence:
**AT1** — `retriggerViaRunReview` forwards all five domain deps to its `runReviewFn` seam, and `runSweep` threads them through the whole cycle:
```
$ cd services/reviewer && bun test --preload ../../tests/setup.ts src/sweeper.test.ts -t "mt#4998"
(pass) mt#4998: ... > forwards all five domain deps, alongside db, to runReview [6.09ms]
(pass) mt#4998: ... > no domain deps → still runs, and names every missing dep on a distinct event (SC3) [0.35ms]
(pass) mt#4998: ... > domain deps present → NO degraded-context event, so an empty specVerification means 'no bound task' rather than 'no taskService' (SC3) [0.17ms]
(pass) mt#4998: ... > a PARTIAL dep set names only what is actually missing [0.16ms]
(pass) mt#4998: ... > a PRESENT but non-SQL persistenceProvider is reported as capability-degraded (PR #3645 R1) [0.14ms]
(pass) mt#4998: ... > runSweep threads SweeperDeps.reviewDomainDeps through to the retrigger [1.12ms]
6 pass
0 fail
21 expect() calls
Ran 6 tests across 1 file. [273.00ms]
```
**AT2** — SC3's discrimination is covered by tests 2–5 above: deps absent → the event fires naming all five; deps present and healthy → the event does NOT fire; a partial set names only what is missing; a present-but-non-SQL provider is reported as capability-degraded with `missingDomainDeps: []`.
**AT3** — the SC4 `runReview` caller enumeration and the grep that produced it are recorded in the spec's `## Findings`. Three invocation sites; all three now use the shared builder; boot recovery was checked specifically and did **not** carry the same omission.
**SC coverage.** SC1, SC3 and SC4 are evidenced above and in `## Live verification`. SC2 cannot be exercised before merge — see the UNVERIFIED paragraph below for why — and is tracked: `[sc2-deferred: mt#5005]`
Full reviewer suite, no regressions:
```
$ cd services/reviewer && bun test --preload ../../tests/setup.ts
2554 pass
0 fail
Ran 2554 tests across 97 files. [4.48s]
```
Typecheck 0 errors across 8 projects (incl. `services/reviewer`, whose tsconfig enables `noUncheckedIndexedAccess`); lint 0 errors / 0 warnings across 4386 files.
AT1 — negative control: reverted the deps object to its exact pre-fix form (`db !== undefined ? { db } : undefined`) and re-ran the same tests.
```
1792 | expect(deps.taskService).toBe(DOMAIN_DEPS.taskService);
error: expect(received).toBe(expected)
(fail) mt#4998: ... > forwards all five domain deps, alongside db, to runReview [6.59ms]
(fail) mt#4998: ... > runSweep threads SweeperDeps.reviewDomainDeps through to the retrigger [1.47ms]
3 pass
2 fail
Ran 5 tests across 1 file. [292.00ms]
```
Both forwarding tests go red; the SC3 observability tests pass either way, which is correct — they cover new behaviour that is independent of the forwarding. The fix was then restored and the suite re-run green (above).
**One pre-existing test was changed, deliberately.** `runSweep > cycle metrics` asserted `expect(callDeps).toBeUndefined()`. That assertion was not merely describing a shape — it *pinned the defect*: it encoded "the sweeper passes no deps at all" as the contract. It is now `toEqual({})`. `{}` and `undefined` are interchangeable to `runReview` (`deps: RunReviewDeps = {}`), so no behaviour changed for that fixture; what changed is that the sweeper now has somewhere to put the deps and production fills it. Called out rather than buried, since silently flipping an assertion is how a real regression hides.
## Live verification
`verify-review-domain-deps.ts` boots the **real** domain container, runs the **real** `buildReviewDomainDeps`, and exercises the two deps whose absence caused the observed symptoms. Run 2026-09-05T01:59:37Z, exit 0:
```json
{
"outcome": "pass",
"presentKeys": ["taskService","persistenceProvider","memoryLookup","askLookup","sessionLookup"],
"missingKeys": [],
"exercised": {
"taskService": { "ok": true, "detail": "getTaskSpecContent(mt#4998) returned 21989 chars" },
"persistenceProvider": { "ok": true, "detail": "capabilities.sql=true" }
}
}
```
This is the binding-direction evidence, not a duplicate of the unit tests. Those hand the seam opaque sentinels and would pass identically if the real builder returned five nulls against a live container — and because every dep fails open, a dead binding is indistinguishable from "this PR has no bound task" at every downstream surface. That is the shape that hid the original defect for as long as it hid.
**Worth recording: the first run of this script was itself a broken probe.** It printed `SKIP: domain container did not boot`, which reads exactly like its own legitimate "no persistence configured" skip. The real cause was a missing `import "reflect-metadata"` — the check could not have passed on any machine. Running it once in the foreground with stderr visible is what surfaced that. A `--require` flag now converts the skip to exit 2 for callers who need it load-bearing.
**UNVERIFIED — and not forceable pre-merge:** that an actual sweeper-initiated review posts a populated `specVerification` and a concrete `Tier`. A sweeper retrigger fires only when a review is genuinely missed on an open PR, and that state cannot be manufactured without mutating production; `reviewer_retrigger` does not substitute, because `POST /retrigger` is a different entry point (`server.ts:1307`) that never reaches `retriggerViaRunReview`. The composed claim rests on two verified halves (forwarding; live working deps) plus the webhook path's existing production behaviour — three of the four reviews on PR #3633 carried populated 4-criterion arrays. So it is `strong-evidence`, not `verified-1b`. Tracked at mt#5005: `[sc2-deferred: mt#5005]` — the first real sweeper fire after deploy settles it, and `sweeper.retrigger_degraded_context` is the signal that would show the wiring had not taken.
Deploy verification: all five changed files return `true` from `isDeploySurfaceFile` (verified by running the predicate over the actual changed-file list, not from a remembered pattern set), so this is deploy surface and carries **no** `[no-deploy-impact]` claim. After merge I will run `deployment_wait-for-latest` for `reviewer` with `notBefore` = the merge timestamp and `expectCommitSha` = the merge SHA, read `buildIdentity`, and assert the `/health` body's `service` identity rather than just its status code.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_012HYHcmDv7NuaD6uK7uAvU2
Co-Authored-By: minsky-ai[bot] <minsky-ai[bot]@users.noreply.github.com>5 files changed
Lines changed: 632 additions & 38 deletions
File tree
- services/reviewer
- scripts
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
| 32 | + | |
32 | 33 | | |
33 | 34 | | |
34 | 35 | | |
| |||
123 | 124 | | |
124 | 125 | | |
125 | 126 | | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | | - | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
46 | 50 | | |
47 | 51 | | |
48 | 52 | | |
| |||
575 | 579 | | |
576 | 580 | | |
577 | 581 | | |
578 | | - | |
579 | | - | |
580 | | - | |
581 | | - | |
582 | | - | |
583 | | - | |
584 | | - | |
585 | | - | |
586 | | - | |
587 | | - | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
588 | 585 | | |
589 | 586 | | |
590 | 587 | | |
| |||
1779 | 1776 | | |
1780 | 1777 | | |
1781 | 1778 | | |
1782 | | - | |
1783 | | - | |
1784 | | - | |
1785 | | - | |
1786 | | - | |
1787 | | - | |
1788 | | - | |
1789 | | - | |
1790 | | - | |
1791 | | - | |
1792 | | - | |
1793 | | - | |
| 1779 | + | |
| 1780 | + | |
1794 | 1781 | | |
1795 | 1782 | | |
1796 | 1783 | | |
| |||
1895 | 1882 | | |
1896 | 1883 | | |
1897 | 1884 | | |
1898 | | - | |
| 1885 | + | |
| 1886 | + | |
| 1887 | + | |
| 1888 | + | |
| 1889 | + | |
1899 | 1890 | | |
1900 | 1891 | | |
1901 | 1892 | | |
| |||
0 commit comments