Skip to content

test(mt#1263): Add runReview sanitize wiring integration tests - #774

Open
minsky-ai[bot] wants to merge 1 commit into
mainfrom
task/mt-1263
Open

test(mt#1263): Add runReview sanitize wiring integration tests#774
minsky-ai[bot] wants to merge 1 commit into
mainfrom
task/mt-1263

Conversation

@minsky-ai

@minsky-ai minsky-ai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds integration tests for the runReview sanitize wiring from mt#1212 / PR #758. Four end-to-end test cases cover the three SanitizeResult.action values through mock.module() stubs.

Key Changes

  • services/reviewer/src/review-worker.test.ts: new describe block with 4 cases (stripped, errored, errored-failure-path, passthrough)
  • eslint.config.js: allowInFiles exception for reviewer test file for no-global-module-mocks rule

Testing

50/50 tests pass. Mutation test confirmed: replacing sanitized.body with output.text caused cases 1 and 2 to fail. Mutation reverted before commit.

Had Claude implement this; AI-authored code.

Three end-to-end tests exercise runReview with mocked dependencies via
mock.module() and a stub-container indirection pattern (property reassignment
on const stubs object avoids both no-const-assign and no-jest-patterns lint):

1. stripped: submitReview receives sanitized.body not raw output.text;
   event parsed from stripped body; reason contains [cot-leakage: stripped]
2. errored: submitReview receives error-notice body; event forced to COMMENT;
   status=error with no review field; submitReview failure does not propagate
3. passthrough: submitReview receives raw output.text; no cot_leak_detected
   console.log emitted

Mutation-test: replacing sanitized.body with output.text in review-worker.ts
caused cases 1 and 2 to fail. Passthrough passes correctly because output.text
IS the passthrough body. Mutation reverted before commit.

Also adds allowInFiles exception for review-worker.test.ts in the
no-global-module-mocks ESLint rule, since the reviewer service has no DI
infrastructure and mock.module() is the only available test seam for runReview.
@minsky-ai minsky-ai Bot added the authorship/co-authored Co-authored by human and AI agent label Apr 24, 2026

@minsky-reviewer minsky-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: unknown


reviewer-service error: chain-of-thought leakage detected

The upstream model emitted raw internal reasoning into the review body. The reviewer service sanitised the output but could not locate a valid Findings section to preserve, so the leaked content was discarded. The PR will receive a fresh review on the next commit. See docs/architecture/critic-constitution-reliability.md for details.

edobry added a commit that referenced this pull request Jul 17, 2026
## Summary

Implements mt#2884 (child of the mt#2880 cockpit product pass): /agents stops sorting by recency and becomes a fleet supervision table ordered by what requires the human — the Claude Code Agent View pattern, which the mt#2880 research identified as the closest production precedent. "Is it alive" (liveness dot) and "does it need me" (needs-me badge) are now two independent visual channels. The subagent tree gains per-node elapsed/running-ended state, delivering the scope of subsumed mt#2041.

## Key Changes

- **`lib/fleet-groups.ts` (new)** — `needsMeBand`: needs-input (an open ask bound to the row's workspace session via `parentSessionId` — a render-side join on the shared `["asks"]` query cache, no new endpoint) → review (non-terminal PR **on a lane active within 7 days** — see below) → working (healthy) → idle (idle/stale) → done. `subagentElapsed`: compact runtime (`44s`, `2m`, `1h 3m`) against now for running nodes, total for ended ones.
- **`Agents.tsx`** — "Needs me" is the DEFAULT sort (band rank, then newest within band; Activity/Status sorts remain); `NeedsMeBadge` renders "needs you" / "in review" as the second channel — absence of a badge IS the calm state; `SubagentRowItem` shows elapsed + running/ended per node.
- **`run-merge.ts`** — `SubagentEntry` gains `endedAt` (additive; the query already selected it), so the tree renders terminal state without a second query.
- **mt#2041 subsumed** (recorded in both specs): per-subagent elapsed, last-activity, and running/ended state now render in the cockpit tree; its DB-query workaround memory retires when this merges.

## The live-audit-driven correction

The first live run exposed a false-alarm class: **20 "in review" badges, all fossils** — 70–87-day-old dead sessions with ancient open/draft PRs (#967, #900, #774…) topping the needs-me order. Same honest-signal failure as the home fleet strip's 217 stale husks (mt#2881). Fix: the review band requires lane activity within `REVIEW_RECENCY_WINDOW_MS` (7 days, matching the repo's recent-merge attention window per /plan-task gate (g)); a fossil lane's open PR is backlog inventory, not a supervision signal. Test added for exactly the audited shape.

## Testing

Execution evidence:

```
bun run test:components   [re-run post-rebase on current main]
 776 pass / 0 fail (1583 expect() calls, 61 files)
   — fleet-groups.test.ts: ask-bound beats healthy; active-lane open PR = review;
     FOSSIL open PR ≠ review (the audited false-alarm shape); merged/closed ≠
     review; liveness banding; band rank ordering; elapsed formats (s/m/h,
     running vs ended, invalid inputs)
```

Typecheck: pass (root + services/reviewer). Lint: 0 errors / 0 warnings.

## Live verification

Prod bundle on :3814 against live data (read-only):

```
activeSort: ["Needs me↑"]  (default)
pre-fix:  20 needs-me badges, all "in review" — every one a 70-87d fossil
post-fix: 0 badges — honest calm (no active lane currently has an open PR or
          bound ask); top row = the one live healthy session (26s ago), green
          liveness dot; fossils demoted below recency order
```

Screenshots reviewed against the objective-defect checklist (memory 67676430): no overlap/clipping; dual channels legible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: minsky-ai[bot] <minsky-ai[bot]@users.noreply.github.com>
edobry added a commit that referenced this pull request Aug 19, 2026
…e SDK retrying blind

## Summary

mt#1897 has been open since 2026-05 asking a question its own substrate cannot answer: **how often does `runReview` actually time out?** This is that task's phase 1. It does not answer the question — it makes the question answerable. Two independent blind spots, both verified against `main@b7a9363` rather than inherited from the parent spec.

### Blind spot 1 — the unrecovered-timeout path persisted nothing

`providers.ts` pushed `"timeout-unrecovered"` onto a local array one statement before `throw`, so it died with the stack frame. That string appeared **exactly once in the entire service** — written, never read.

All three non-test `recordReviewTiming` call sites sit downstream of that throw:

| site | path | reachable on an unrecovered timeout? |
| --- | --- | --- |
| `review-worker.ts:379` | routing-skip (pre-model) | no |
| `review-worker.ts:443` | concurrent-inflight skip (pre-model) | no |
| `review-finalize.ts:168` | post-model, all four terminal paths | no — the throw goes past finalize |

**Measured consequence:** 30 days of `review_timing` contained **zero** rows carrying it, while this repo produced at least five unrecovered timeouts on 2026-08-18 alone (PRs #3095, #3098, #3108, #3113, #3109). So every percentile in mt#1897 — including the p99 its "the cap is already 2× p99" conclusion rests on — is bounded to the **recovered** class. That bound is now annotated on the parent spec at the figures themselves.

Fixed by carrying the partial timing out on the error and writing one row at the `runReview` boundary. A non-enumerable symbol property rather than a field on `TimeoutError`, which lives in `with-timeout.ts` and is shared with `merge-state-sweeper.ts` — reviewer timing has no business in its shape. The boundary **rethrows**: this adds a row, it does not recover, and `review_error` still surfaces exactly as before.

**Class-not-instance.** The tool-loop catch prompted this, but `callOpenAIWithClient` has a *second* model-call site — the `notools` single-turn path — with its own `withTimeout` and its own success-only `timing` block, losing the same data the same way. It was found because the first draft of these tests omitted `tools` and silently took that branch. Both are fixed, and the test table now pins both so neither stands in for the other.

### Blind spot 2 — the OpenAI SDK was retrying invisibly inside the 120s budget

The client was `new OpenAI({ apiKey: config.providerApiKey })` — no `timeout`, no `maxRetries`.

Verified against **the installed openai@4.104.0**, not the `master` README, which documents v6. That mismatch is the point: the docs I first reached describe a different major version, so the installed source is the citation throughout.

- `index.d.ts`: `[opts.maxRetries=2]`, `[opts.timeout=10 minutes]`
- `core.js#shouldRetry`: retries on **408, 409, 429, and any ≥ 500**, plus connection errors
- `index.d.ts` line 49, unprompted: *"Note that request timeouts are retried by default, so in a worst-case scenario you may wait much longer than this timeout before the promise succeeds or fails."*

Two consequences. The SDK's own 10-minute timeout is unreachable — the 120s `withTimeout` wrapper always fires first, so `APIConnectionTimeoutError` is never observed. And **a 429 retry chain was indistinguishable from one stuck call**: up to three attempts plus backoff run inside a single `chat.completions.create()`, emitting nothing this service logs. From outside, the wrapper simply fires at 120s.

Both values are now **pinned to what we were already inheriting**, and a wrapping `fetch` logs any retryable response with the SDK's own attempt number — read off the `x-stainless-retry-count` header it already stamps (`maxRetries - retriesRemaining`), rather than derived. Absent header logs `null` rather than a fabricated number, because an invented attempt count reads as measured.

**Explicitly not a tuning move.** Retry policy is a Class B guarantee trade under the July 2026 cost audit, owned by mt#2718 and mt#3526, needing a measured before/after and principal sign-off. Pinning an inherited value is behaviour-preserving; changing it would not be. A test asserts the constants equal the defaults they replace, so a future change to either is a deliberate act rather than drift. The 10-minute timeout is deliberately left *above* the wrapper — lowering it would start it firing.

### A figure this revises

The July 2026 cost audit computes *"up to 24"* calls per review, over the two application-level retry layers. The SDK layer sits **underneath** both and multiplies by up to 3, so the real worst-case HTTP attempt count is ~72. No concurrency or rate-limit reasoning should use 24 as an upper bound until the new instrumentation says otherwise.

### Why this is a hypothesis-tester, not a hypothesis

Blind spot 2 is a strong candidate for the residual mt#1897 could not place — it predicts the three properties that survived that task's falsification round (failures at ~2× p99, i.e. *off* the distribution rather than in its tail; time-clustering, 15 of 25 on one day; a monotone rise with concurrency) and explains why `status.openai.com` read `operational` throughout, since per-key 429s are not a status-page incident. **It is labelled `inferred` and is asserted nowhere in the code or the spec.** This PR ships the instrumentation that can test it. The two facts it acts on are directly verified and each independently justifies the change.

## Key Changes

- `providers.ts` — `attachPartialTiming` / `extractPartialTiming`; the `TIMEOUT_UNRECOVERED` constant; the carrier attached at **both** throw sites; `createReviewerOpenAIClient` with pinned `timeout`/`maxRetries`; `withSdkRetryVisibility` + `readSdkAttempt`; `isSdkRetryableStatus` verified against the installed `core.js`.
- `review-timing.ts` — `recordUnrecoveredReviewTiming`, the third timing shape. Token fields are **omitted, not zeroed**: a review that never returned usage has unknown spend, and zeroes would understate cost in the same aggregate the cost audit reads.
- `review-worker.ts` — the `runReview` boundary catch: record, then rethrow.
- `unrecovered-timing.test.ts` — new. Deliberately **not** in `review-worker.test.ts`, which open PR #774 rewrites (its only two files are that test and `eslint.config.js`; this PR touches neither).

No new `review_timing` column — `retryOutcomes` is already `string[]`, so the contract-propagation gate does not fire.

## Testing

Every seam is a real parameter — `callOpenAIWithClient` takes its `client`, `recordUnrecoveredReviewTiming` takes its `timingRecorder`, `withSdkRetryVisibility` takes its `baseFetch` — so no test here patches a module import.

Execution evidence:

```
$ bun --cwd services/reviewer test --preload ../../tests/setup.ts --timeout=15000 src/unrecovered-timing.test.ts
 39 pass
 0 fail
 63 expect() calls
Ran 39 tests across 1 file. [111.00ms]

$ bun --cwd services/reviewer test --preload ../../tests/setup.ts --timeout=20000
 2116 pass
 0 fail
 4628 expect() calls
Ran 2116 tests across 77 files. [4.59s]
```

Acceptance tests, by the spec's own numbering:

- **AT1** (one row carrying `timeout-unrecovered`) — `writes exactly ONE row carrying timeout-unrecovered`.
- **AT2** (negative control: the error still propagates) — `NEGATIVE CONTROL: the error still propagates`, on both call paths. A test asserting only the row would pass against an implementation that swallowed the failure, which would be worse than the bug.
- **AT3** (no double-write) — **structural, and stated as such rather than claimed as a test:** the boundary catch rethrows, so control never reaches `finalizeReviewSuccess` / `finalizeReviewError`, and `writeMainPathTiming` cannot run on this path. The two writes are mutually exclusive by control flow.
- **AT4** (partial latencies survive) — `partial latencies are PERSISTED, not dropped to an empty array`.
- **AT5** (SDK retry visible, with a negative control) — `a 429 ... is reported`, `reports the SDK's own 1-based attempt number`, `the FIRST attempt reads as 1`, `reports null rather than fabricating a number`, plus `NEGATIVE CONTROL: a clean 200 reports nothing`. Without the last, an always-firing callback would pass the others and make every successful call look like a retry.
- **AT6** (explicit client config) — `PINS maxRetries and timeout instead of inheriting SDK defaults`.

Negative control — tool-loop attach reverted to a bare `throw err`:

```
(fail) ... > tool-use loop > an unrecovered TimeoutError arrives carrying timeout-unrecovered
(fail) ... > tool-use loop > NEGATIVE CONTROL: a non-timeout throw records no timeout outcome
 34 pass
 2 fail
```

Exactly the two tool-loop tests fail while the `notools` ones still pass — so the paths are independently covered rather than one standing in for both. (The `notools` half got its control for free: it was discovered *by* those tests failing before it was fixed.)

Production wiring, caller direction — helper unit tests are not evidence of a caller:

```
$ grep -rn "recordUnrecoveredReviewTiming" services/reviewer/src/ --include='*.ts' | grep -v '\.test\.ts'
services/reviewer/src/review-timing.ts:84:export async function recordUnrecoveredReviewTiming(
services/reviewer/src/review-worker.ts:62:  recordUnrecoveredReviewTiming,
services/reviewer/src/review-worker.ts:885:    await recordUnrecoveredReviewTiming({
```

Typecheck: 0 errors across 8 projects. Lint: 0 errors, 0 warnings, 3783 files.

## Live verification

**UNVERIFIED — with the reason, per the rule that a runtime you did not attempt is a skipped step rather than a substitution.**

The behaviour added here fires only on failures that cannot be induced on demand against production: an unrecovered 120s toolloop timeout, and a 429/5xx from OpenAI. There is no safe way to force either from a session, and forcing them would degrade the live reviewer for every other PR in flight. The substitute available — a stub transport and a stub client — is what the tests above use, and it validates the LOGIC, not its reachability in the real runtime. Note the negative control does not close this gap either: it proves the probe can fail, never that it is observing the right system.

Post-deploy both become observable without being inducible, and this is what to check:

1. `select * from review_timing where 'timeout-unrecovered' = any(retry_outcomes)` — expected to stay empty until the next unrecovered timeout, then become non-empty **for the first time in the table's history**. That transition is the real proof.
2. `openai.sdk_retryable_response` in the reviewer's runtime logs — expected on the next 429/5xx, carrying `status` and `attempt`.

Neither is a deploy-health signal, so neither is satisfied by the deploy succeeding.

## Deploy verification

Touches deployed source under `services/reviewer/src/**`. After merge: `deployment_wait-for-latest` with `notBefore` set to the merge timestamp, then assert the health body's service identity is `minsky-reviewer` — not merely a 200 (mt#3148). No new external-system integration: same OpenAI API, same credential, no new scope, permission, or webhook, so gate (n)'s live-exercise class does not apply.

## Scope boundary

Out of scope, with owners named: `MAX_TOOL_ROUNDS` and retry policy as cost levers (mt#2718; mt#3526 specifically for the round cap, where 83% of calls terminate at the 10-round limit); the Responses API migration (mt#1897's decide phase); Braintrust spans (mt#1885, which instruments only the success path and shares the blind spot this closes).

**mt#2119** (per-review correlation ID) touches the same logging call sites — `callReviewerWithRetry`, `callOpenAIWithClient`, `withTimeout` — and is complementary rather than overlapping: it adds correlation to lines that already exist, this adds a line that did not. The new retry log is materially less useful without it when the whole problem is concurrent reviews interleaving.

Co-Authored-By: minsky-ai[bot] <minsky-ai[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

authorship/co-authored Co-authored by human and AI agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant