Skip to content

Commit 5de52cb

Browse files
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>
2 parents 0715857 + b9f6ab8 commit 5de52cb

5 files changed

Lines changed: 632 additions & 38 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Live binding check for the review's domain context (mt#4998).
4+
*
5+
* ## What only a live run can answer
6+
*
7+
* `sweeper.test.ts`'s mt#4998 block proves the deps are FORWARDED — it hands
8+
* `retriggerViaRunReview` opaque sentinels and asserts they arrive at
9+
* `runReview`. That is a seam-injected test, and per
10+
* `/implement-task` §7 item 8 (binding direction) a seam-injected test is not
11+
* evidence for the REAL-WIRED binding of the seam. It would pass identically
12+
* if `buildReviewDomainDeps` returned five nulls against a live container.
13+
*
14+
* That distinction is not hypothetical here. Every dep in this set is
15+
* `?: T | null` and degrades SILENTLY when absent — a null `taskService`
16+
* produces `specVerification: []`, a null `persistenceProvider` produces
17+
* `Tier: unknown`, and the review posts and looks normal either way. A
18+
* never-worked binding is therefore indistinguishable from "this PR has no
19+
* bound task" at every downstream surface, which is exactly the fail-open
20+
* shape §7 item 8 flags as the extra red flag. It is also how the original
21+
* defect survived in production unnoticed.
22+
*
23+
* So this script boots the REAL domain container, runs the REAL
24+
* `buildReviewDomainDeps`, and then EXERCISES the two deps whose absence
25+
* caused the observed symptoms rather than merely null-checking them.
26+
*
27+
* ## What it deliberately does NOT cover
28+
*
29+
* It does not drive a review. Whether a sweeper-initiated review ultimately
30+
* posts a populated `specVerification` array depends on the model as well as
31+
* the wiring, and forcing a real sweeper retrigger requires a genuinely
32+
* missed review in production, which cannot be manufactured pre-merge. This
33+
* verifies the half that is ours: that the container yields live, working
34+
* deps and that the builder passes them through. The remaining half is named
35+
* UNVERIFIED in the PR body rather than implied.
36+
*
37+
* It also does not exercise `memoryLookup` / `askLookup` / `sessionLookup`
38+
* beyond a null check — those resolve `mem#N` / `ask#N` / `ws#N` references
39+
* that only appear in some specs, they were never implicated in the observed
40+
* symptoms, and calling them would require a record known to exist. Stated
41+
* rather than implied, because a check that looks total and is not is worse
42+
* than one whose bound is written down.
43+
*
44+
* ## Usage
45+
*
46+
* bun services/reviewer/scripts/verify-review-domain-deps.ts [--task=mt#4998] [--require]
47+
*
48+
* Exits 0 when every dep is present and both exercised deps respond, 2 on any
49+
* failure, and 0 with a SKIP notice when the domain container cannot boot
50+
* (no persistence configured — safe in an unconfigured CI job). Pass
51+
* `--require` to turn that SKIP into an exit 2 instead. Writes
52+
* `services/reviewer/scripts/verify-review-domain-deps-results.json`.
53+
*/
54+
55+
// The domain container resolves tsyringe-decorated classes, which need the
56+
// reflect-metadata polyfill installed before any of them is constructed —
57+
// same reason `server.ts:16` and the other container-booting scripts import
58+
// it first. Without this the boot throws a polyfill error that this script's
59+
// catch would report as a SKIP, which reads exactly like "no persistence
60+
// configured" and would make the check silently incapable of passing.
61+
import "reflect-metadata";
62+
import { writeFileSync } from "node:fs";
63+
import { join, dirname } from "node:path";
64+
import { fileURLToPath } from "node:url";
65+
import { bootDomainContainer, buildReviewDomainDeps } from "../src/domain-container";
66+
67+
const RESULTS_PATH = join(
68+
dirname(fileURLToPath(import.meta.url)),
69+
"verify-review-domain-deps-results.json"
70+
);
71+
72+
/**
73+
* The keys `buildReviewDomainDeps` must populate. Declared here rather than
74+
* imported from `sweeper.ts` so this script fails if the two ever diverge —
75+
* an independent statement of the contract, not an echo of it.
76+
*/
77+
const REQUIRED_KEYS = [
78+
"taskService",
79+
"persistenceProvider",
80+
"memoryLookup",
81+
"askLookup",
82+
"sessionLookup",
83+
] as const;
84+
85+
interface Results {
86+
ranAt: string;
87+
outcome: "pass" | "fail" | "skip";
88+
skipReason?: string;
89+
presentKeys: string[];
90+
missingKeys: string[];
91+
exercised: {
92+
taskService: { ok: boolean; taskId: string; detail: string };
93+
persistenceProvider: { ok: boolean; detail: string };
94+
} | null;
95+
}
96+
97+
function arg(name: string): string | undefined {
98+
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
99+
return hit?.slice(name.length + 3);
100+
}
101+
102+
function finish(results: Results, code: number): never {
103+
writeFileSync(RESULTS_PATH, `${JSON.stringify(results, null, 2)}\n`);
104+
console.log(JSON.stringify(results, null, 2));
105+
console.log(`\nWrote ${RESULTS_PATH}`);
106+
process.exit(code);
107+
}
108+
109+
async function main(): Promise<void> {
110+
const taskId = arg("task") ?? "mt#4998";
111+
const requireBoot = process.argv.includes("--require");
112+
const ranAt = new Date().toISOString();
113+
114+
let domainServices: Awaited<ReturnType<typeof bootDomainContainer>>;
115+
try {
116+
domainServices = await bootDomainContainer();
117+
} catch (err) {
118+
const reason = err instanceof Error ? err.message : String(err);
119+
console.log(`SKIP: domain container did not boot — ${reason}`);
120+
finish(
121+
{
122+
ranAt,
123+
outcome: "skip",
124+
skipReason: reason,
125+
presentKeys: [],
126+
missingKeys: [...REQUIRED_KEYS],
127+
exercised: null,
128+
},
129+
requireBoot ? 2 : 0
130+
);
131+
}
132+
133+
// The real builder, on the real container — the binding under test.
134+
const deps = buildReviewDomainDeps(domainServices) as Record<string, unknown>;
135+
136+
const presentKeys = REQUIRED_KEYS.filter((k) => deps[k] != null);
137+
const missingKeys = REQUIRED_KEYS.filter((k) => deps[k] == null);
138+
139+
// Exercise, don't just null-check. A non-null handle to a dead binding is
140+
// the failure mode this script exists to catch (mem#654 / §7 item 8): the
141+
// reviewer widget's DB layer threw on every query for ~5 weeks while
142+
// rendering healthy zeros.
143+
let taskOk = false;
144+
let taskDetail = "not attempted (taskService missing)";
145+
if (deps["taskService"] != null) {
146+
try {
147+
const spec = await domainServices.taskService.getTaskSpecContent(taskId);
148+
const content = (spec as { content?: string } | null)?.content ?? "";
149+
taskOk = content.length > 0;
150+
taskDetail = taskOk
151+
? `getTaskSpecContent(${taskId}) returned ${content.length} chars`
152+
: `getTaskSpecContent(${taskId}) returned empty content`;
153+
} catch (err) {
154+
taskDetail = `getTaskSpecContent(${taskId}) threw: ${
155+
err instanceof Error ? err.message : String(err)
156+
}`;
157+
}
158+
}
159+
160+
let persistenceOk = false;
161+
let persistenceDetail = "not attempted (persistenceProvider missing)";
162+
if (deps["persistenceProvider"] != null) {
163+
try {
164+
// `resolveTier`'s ProvenanceService lookup needs SQL capability; a
165+
// provider that reports no SQL capability silently yields `Tier: unknown`
166+
// exactly as a null provider does.
167+
const caps = domainServices.persistenceProvider.capabilities;
168+
persistenceOk = caps?.sql === true;
169+
persistenceDetail = `capabilities.sql=${String(caps?.sql)}`;
170+
} catch (err) {
171+
persistenceDetail = `capabilities read threw: ${
172+
err instanceof Error ? err.message : String(err)
173+
}`;
174+
}
175+
}
176+
177+
const outcome: Results["outcome"] =
178+
missingKeys.length === 0 && taskOk && persistenceOk ? "pass" : "fail";
179+
180+
finish(
181+
{
182+
ranAt,
183+
outcome,
184+
presentKeys: [...presentKeys],
185+
missingKeys: [...missingKeys],
186+
exercised: {
187+
taskService: { ok: taskOk, taskId, detail: taskDetail },
188+
persistenceProvider: { ok: persistenceOk, detail: persistenceDetail },
189+
},
190+
},
191+
outcome === "pass" ? 0 : 2
192+
);
193+
}
194+
195+
await main();

services/reviewer/src/domain-container.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
SqlCapablePersistenceProvider,
3030
} from "@minsky/domain/persistence/types";
3131
import type { MemoryLookup, AskLookup } from "./short-id-fetch";
32+
import type { RunReviewDeps } from "./review-worker";
3233

3334
export interface DomainServices {
3435
container: AppContainerInterface;
@@ -123,3 +124,63 @@ export async function bootDomainContainer(): Promise<DomainServices> {
123124

124125
return { container, sessionProvider, taskService, persistenceProvider, memoryLookup, askLookup };
125126
}
127+
128+
/**
129+
* The subset of `RunReviewDeps` that carries DOMAIN CONTEXT into a review —
130+
* as opposed to the test seams that make up the rest of that interface.
131+
*
132+
* Every one of these is silently optional in `runReview`, and each degrades a
133+
* different part of the review rather than failing it:
134+
*
135+
* | Missing dep | Consequence |
136+
* | --------------------- | -------------------------------------------------------- |
137+
* | `taskService` | `resolveTaskSpec` → null → `specVerification: []` |
138+
* | `persistenceProvider` | `resolveTier` skips ProvenanceService → `Tier: unknown` |
139+
* | `memoryLookup` | `mem#N` criteria references unresolved (mt#3964) |
140+
* | `askLookup` | `ask#N` criteria references unresolved (mt#3964) |
141+
* | `sessionLookup` | `ws#N` criteria references unresolved (mt#3964) |
142+
*
143+
* @see mt#4998 — the defect that motivated extracting this.
144+
*/
145+
export type ReviewDomainDeps = Pick<
146+
RunReviewDeps,
147+
"taskService" | "persistenceProvider" | "memoryLookup" | "askLookup" | "sessionLookup"
148+
>;
149+
150+
/**
151+
* Map booted `DomainServices` onto the review's domain-context deps.
152+
*
153+
* ## Why this exists rather than each caller spreading the fields itself
154+
*
155+
* There are three `runReview` entry points — the webhook handler, boot
156+
* recovery, and the missed-review sweeper's retrigger — and until mt#4998 each
157+
* assembled this object by hand. Two did it identically; the sweeper passed
158+
* only `{ db }`, so EVERY sweeper-initiated review ran with no tier resolution
159+
* and no bound-task spec, posting `Tier: unknown` with an empty
160+
* `specVerification` array. Nothing failed, because each dep degrades quietly
161+
* by design (see the table above) — the review simply came out weaker than the
162+
* one a webhook would have produced for the same commit.
163+
*
164+
* A hand-assembled dep list is exactly the shape that admits that defect: the
165+
* omission is invisible at the call site and invisible in the output. One
166+
* builder means a fourth entry point cannot repeat it.
167+
*
168+
* Returns `{}` when the container never booted (DB-less or degraded start), so
169+
* callers keep today's graceful-degradation behaviour rather than throwing —
170+
* the sweeper is a best-effort safety net and must still run without a
171+
* container.
172+
*
173+
* @see mt#2121 — the direct-domain-import path these deps came from.
174+
* @see mt#4998 — the sweeper omission this closes.
175+
*/
176+
export function buildReviewDomainDeps(domainServices?: DomainServices): ReviewDomainDeps {
177+
if (!domainServices) return {};
178+
return {
179+
taskService: domainServices.taskService,
180+
persistenceProvider: domainServices.persistenceProvider,
181+
// mt#3964: mem#N / ask#N / ws#N criteria-reference resolution.
182+
memoryLookup: domainServices.memoryLookup,
183+
askLookup: domainServices.askLookup,
184+
sessionLookup: domainServices.sessionProvider,
185+
};
186+
}

services/reviewer/src/server.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ import { loadAdoptionSweeperConfig, startAdoptionSweeper } from "./adoption-swee
4242
import { getDb, type ReviewerDb } from "./db/client";
4343
import { applyMigrations } from "./db/migrate";
4444
import { recoverPendingReviews, loadBootRecoveryConfig } from "./boot-recovery";
45-
import { bootDomainContainer, type DomainServices } from "./domain-container";
45+
import {
46+
bootDomainContainer,
47+
buildReviewDomainDeps,
48+
type DomainServices,
49+
} from "./domain-container";
4650
import {
4751
recordWebhookReceipt,
4852
updateOutcome,
@@ -575,16 +579,9 @@ export function createApp(
575579
headSha,
576580
{
577581
...(db !== undefined ? { db } : {}),
578-
...(domainServices
579-
? {
580-
taskService: domainServices.taskService,
581-
persistenceProvider: domainServices.persistenceProvider,
582-
// mt#3964: mem#N / ask#N / ws#N criteria-reference resolution.
583-
memoryLookup: domainServices.memoryLookup,
584-
askLookup: domainServices.askLookup,
585-
sessionLookup: domainServices.sessionProvider,
586-
}
587-
: {}),
582+
// mt#4998: one builder for all three runReview entry points. Assembling
583+
// this list by hand is what let the sweeper's copy omit it entirely.
584+
...buildReviewDomainDeps(domainServices),
588585
}
589586
)
590587
.then((result) => {
@@ -1779,18 +1776,8 @@ if (import.meta.main) {
17791776
config,
17801777
loadBootRecoveryConfig(),
17811778
runReview,
1782-
{
1783-
...(domainServices
1784-
? {
1785-
taskService: domainServices.taskService,
1786-
persistenceProvider: domainServices.persistenceProvider,
1787-
// mt#3964: mem#N / ask#N / ws#N criteria-reference resolution.
1788-
memoryLookup: domainServices.memoryLookup,
1789-
askLookup: domainServices.askLookup,
1790-
sessionLookup: domainServices.sessionProvider,
1791-
}
1792-
: {}),
1793-
},
1779+
// mt#4998: shared builder — see the webhook site above.
1780+
buildReviewDomainDeps(domainServices),
17941781
// mt#4881: a recovered review that fails must reach the operator the same
17951782
// way a live one does. Built here from the same container the sweeper and
17961783
// createApp use; undefined when no container booted.
@@ -1895,7 +1882,11 @@ if (import.meta.main) {
18951882
// `undefined`) — so it never blocks startup either way. See sweeper.ts
18961883
// module-header "Boot catch-up sweep" for the diagnosis of why the
18971884
// pre-mt#2660 sweeper missed PR #1812's webhook for 25+ minutes.
1898-
startSweeper(config, loadSweeperConfig(), db, domainServices?.container, alertSink);
1885+
// mt#4998: pass the whole DomainServices, not just its container — the
1886+
// sweeper's retriggers must resolve the same tier and bound-task spec a
1887+
// webhook review does. Passing `.container` alone is what left every
1888+
// sweeper-initiated review posting `Tier: unknown` with no spec verification.
1889+
startSweeper(config, loadSweeperConfig(), db, domainServices, alertSink);
18991890

19001891
// Start the PR-watch scheduler (mt#1618 / mt#1899).
19011892
// Uses domain imports (mt#2121) via the booted domain container — no MCP-over-HTTP.

0 commit comments

Comments
 (0)