feat(rmp): comparative quorum modules — quorum-in-module + distributed quorum #4

Merged
hibryda merged 12 commits from feat/rmp-quorum-modules into main 2026-06-03 00:43:17 +02:00
Owner

Builds the two comparative quorum review modules over the RMP layer, plus the cross-process container split and a reliability-weighted comparison harness.

Modules

  • Module A — quorum-in-module (src/rmp/quorum-module.ts): runs the whole Quorum (2 reviewers + arbiter) inside one process, emitting one event stream + one terminal result. Leg-tagged activity for the status column.
  • Module B — distributed quorum (src/rmp/distributed-quorum.ts): fans two single-reviewer legs out as separate module runs and reconciles centrally. Reliability-first per-leg failure isolation — both-fail → fail-closed; one-fail → DEGRADE to the surviving leg's verdict. Heterogeneous-leg capable (per-leg model). Pluggable leg-runner: in-process or cross-process (transport).

Shared + infra

  • quorum-reconcile.ts: isQuiescent/mergeQuiescent extracted from Quorum (behaviour-preserving — 34 quorum tests still pass) so both modules reconcile identically.
  • Leg-tagged RmpEvent (additive leg) + leg-aware DriverSink.
  • Cross-process transport: per-module request streams, the enqueue→events→ReviewResult round-trip, cancel pub/sub, the module-host consume-loop, module-bootstrap + bin/module-claude{,-quorum}.ts container entrypoints, 3 docker-compose services (rmp-modules profile).
  • compare.ts + bin/compare-modules.ts: reliability-weighted comparison (bucketed verdict agreement: NO_NEW_FINDINGS ≈ APPROVE) → structured report.

Tests

+~30 tests across the new modules/transport/host/compare; full suite 2135 green; typecheck clean. The container split is exercised live via the rmp-modules profile + the compare driver.

Builds the two comparative quorum review modules over the RMP layer, plus the cross-process container split and a reliability-weighted comparison harness. ## Modules - **Module A — quorum-in-module** (`src/rmp/quorum-module.ts`): runs the whole `Quorum` (2 reviewers + arbiter) inside one process, emitting one event stream + one terminal result. Leg-tagged activity for the status column. - **Module B — distributed quorum** (`src/rmp/distributed-quorum.ts`): fans two single-reviewer legs out as separate module runs and reconciles centrally. **Reliability-first per-leg failure isolation** — both-fail → fail-closed; one-fail → DEGRADE to the surviving leg's verdict. Heterogeneous-leg capable (per-leg model). Pluggable leg-runner: in-process or cross-process (transport). ## Shared + infra - `quorum-reconcile.ts`: `isQuiescent`/`mergeQuiescent` extracted from `Quorum` (behaviour-preserving — 34 quorum tests still pass) so both modules reconcile identically. - Leg-tagged `RmpEvent` (additive `leg`) + leg-aware `DriverSink`. - Cross-process transport: per-module request streams, the enqueue→events→ReviewResult round-trip, cancel pub/sub, the `module-host` consume-loop, `module-bootstrap` + `bin/module-claude{,-quorum}.ts` container entrypoints, 3 `docker-compose` services (rmp-modules profile). - `compare.ts` + `bin/compare-modules.ts`: reliability-weighted comparison (bucketed verdict agreement: NO_NEW_FINDINGS ≈ APPROVE) → structured report. ## Tests +~30 tests across the new modules/transport/host/compare; full suite 2135 green; typecheck clean. The container split is exercised live via the rmp-modules profile + the compare driver.
Comparative quorum modules, part 1 of 2 (Module A).

- events.ts: additive optional `leg` tag on the leg-attributable RmpEvent
  variants (round/progress/activity/telemetry/note/degraded/stalled) + an
  `eventLeg()` helper + an `RmpLeg` type. Whole-request events
  (accepted/phase/result/failed) carry no leg; existing single-reviewer
  producers + consumers stay byte-compatible.
- driver.ts: DriverSink gains leg-aware routing (leg threaded into
  onProgress/onDegraded/onStalled/onNote) + an onRound hook; the driver now
  routes the `round` event.
- quorum-module.ts: runQuorumModule (Module A) drives the Quorum class inside
  one process and emits one event stream + one terminal result. Telemetry from
  the interleaved onMessage (no double-count); per-leg activity from
  onLegMessage (leg-tagged); the arbiter phase/round/activity from
  onArbiterStateChange. Fail-closed.

Tests: phase flow (prompting→reviewing→arbitrating→parsing), leg tagging
(a/b/arbiter), arbiter round, quiescent skip, fail-closed, eventLeg helper.
Comparative quorum modules, part 2 of 2 (Module B).

- quorum-reconcile.ts: isQuiescent/mergeQuiescent/mergeUsedJsonRetry extracted
  verbatim from Quorum into pure functions. Quorum now delegates (re-exports
  mergeUsedJsonRetry for back-compat); the 34-test quorum suite still passes, so
  the extraction is behaviour-preserving. Both quorum modules reconcile through
  this one path, so the comparison isolates topology, not reconciliation drift.
- descriptor.ts: makeDistributedQuorumDescriptor (distributing → arbitrating →
  parsing).
- distributed-quorum.ts: runDistributedQuorum fans out two single-reviewer legs
  (in-process; heterogeneous-leg capable via per-leg runner + model), re-emits
  their events leg-tagged, then reconciles centrally via the shared logic.
  Reliability-first per-leg failure isolation: both-fail → terminal failed;
  one-fail → DEGRADE to the surviving leg's verdict (degraded event) rather than
  losing the review; both-ok → quiescent merge or arbiter.

Tests: divergent (arbitrates), quiescent (merge, no arbiter), one-leg-failure
(degrade to survivor), both-fail (fail-closed). Full suite 2121 pass.
compare.ts runs each topology over the same ReviewRequest, reduces its event
stream to a ModuleRunReport (verdict + bucket, finding breakdown, latency,
tokens/cost, rate-limits, arbiter-fired, degraded, per-leg metrics), and diffs
the two — reliability first (did each produce a verdict? did a leg degrade/fail?
do they agree?), cost/latency last.

Verdict agreement is BUCKETED (blocking {NEEDS_WORK,BLOCKED} vs non-blocking
{APPROVE,CONDITIONAL_APPROVE,NO_NEW_FINDINGS}), so a NO_NEW_FINDINGS round and an
APPROVE round AGREE; exact verdict equality is recorded alongside for the
fine-grained view. renderComparisonMarkdown emits a reliability-first report.

A `(emit) => Promise<void>` topology-run shape so it drives the in-process
modules now and a cross-process event reader later. Pure + clock-injectable;
10 deterministic tests. (bin/compare-modules.ts live runner lands with P5.)
P3 of the comparative quorum modules (the testable container-split infra; no
deploy).

- keys.ts: requestsFor(moduleId) — per-module request streams so the distributed
  orchestrator routes each leg to a specific (heterogeneous) module, alongside
  the shared any-module stream.
- transport.ts: enqueue/claim/ack/ensureGroup take an optional moduleId
  (back-compat: unset → shared stream). runReviewViaTransport — the serving-side
  cross-process driver: enqueue a request, then drive runReviewViaModule over the
  resumable readReviewEvents generator (identical serving path in-process or
  containerised). subscribeCancellations — module-side cancel listener over a
  duplicate() SUBSCRIBE connection, fail-safe on malformed/foreign messages.

Tests: per-module routing isolation, the enqueue→events→ReviewResult round-trip,
cancel dispatch (parse + channel/malformed filtering). Existing 7 transport tests
still green (back-compat).
P4 (1/n). createModuleHost turns any review module into a stand-alone container
process: ensure-group + register/heartbeat in the registry, claim requests off
the module's stream, run the review with events serialized back onto the
request's event stream (stream order == emit order), ack on terminal, and abort
in-flight reviews on a published cancellation. SDK-agnostic — the runReview glue
(real Reviewer/Quorum) lives in the bin entrypoints — so the loop is unit-tested
against the redis mock with a stub. A backstop emits a terminal failed if the
glue throws, so the serving driver never hangs on a silent stream.

Tests: claim→emit-in-order→ack→register, and the throw backstop.
P4 (2/n). Refactor runDistributedQuorum to a pluggable LegRunner strategy
(behaviour-preserving — the 4 in-process tests still pass): the per-event
translation (swallow leg-accepted, collapse leg-phase to leg-tagged activity,
re-emit the rest tagged, capture result/failed as the LegOutcome) is extracted to
consumeLegEvent and shared by both runners.

- makeInProcessLegRunner — the default (a runClaudeModule per leg).
- makeTransportLegRunner — fans each leg out to a module CONTAINER: ensure the
  leg's request group (cold-start safety), enqueue the leg request to its module
  stream, drive its event stream. Per-leg failure isolation holds across the wire
  (a leg whose stream ends without a terminal degrades to the surviving leg).
- DistributedLegSpec gains moduleId (heterogeneous routing) + LegOutcome/LegRunner
  exported.

Tests: cross-process divergent→arbitrate over two leg streams; dead-leg→degrade.
P4 (3/n). The container side of the split:
- module-bootstrap.ts: runModuleContainer — the shared startup (env config +
  fail-fast on FORGEJO_TOKEN, Redis + a duplicate() SUBSCRIBE connection, Forgejo
  client + SDK semaphore), wires the caller's runReview glue into createModuleHost
  and runs the loop until SIGTERM/SIGINT (drain in-flight, then clean exit). No
  diff fetch — the request carries the pre-fetched diff + shared-volume workdir.
- bin/module-claude.ts: single-reviewer module container (a distributed-quorum
  leg or the reference single-shot module); RMP_MODULE_ID routes the stream.
- bin/module-claude-quorum.ts: the quorum-in-module container (Module A).

Both reuse the engine image with a different tsx command (the engine/viewport
pattern). Typecheck clean; full suite 2135 (container glue is integration-tested
live in P5).
feat(rmp): module compose services + the live comparison driver
All checks were successful
pr-reviewer CI / tsc --noEmit (pull_request) Successful in 23s
pr-reviewer CI / tsc build (pull_request) Successful in 34s
pr-reviewer CI / vitest (pull_request) Successful in 1m1s
062414e951
P4 (4/4). The deployable container split + the operator comparison runner.

- docker-compose.yml: three RMP module services behind a `rmp-modules` profile
  (a normal deploy is unaffected) — module-quorum (Module A), module-leg-a,
  module-leg-b (Module B's two legs). Each reuses the engine image with a module
  entrypoint, mounts the shared repo-workdir volume read-only (sees the engine's
  PR-HEAD checkout), and runs MEMORA-disabled so the comparison has no
  cross-module memory race. The per-leg model rides the request, so the leg
  services are identical code.
- bin/compare-modules.ts: fetches the PR + diff, stages the PR-HEAD checkout into
  the shared volume, then runs BOTH topologies CROSS-PROCESS over the live
  containers (Module A via runReviewViaTransport→claude-quorum; Module B via
  runDistributedQuorum + the transport leg-runner→leg-a/leg-b + an in-orchestrator
  arbiter) and prints the reliability-weighted report. The engine's review path is
  untouched.

Typecheck clean; full suite 2135. Container + driver glue is validated live (P5).
docs(changelog): record the comparative quorum modules + live validation
All checks were successful
pr-reviewer CI / tsc --noEmit (pull_request) Successful in 23s
pr-reviewer CI / vitest (pull_request) Successful in 1m0s
pr-reviewer CI / tsc build (pull_request) Successful in 36s
26fd8db7a2
The two quorum modules (A: quorum-in-module, B: distributed quorum), the
cross-process container split, the comparison harness, and the live A/B
validation on the NAS (PR #5 — both topologies APPROVE + agree).

hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer)

Round 1 — head 062414e95196, base main, trigger opened

TL;DR: CONDITIONAL_APPROVE — kept 1 agreed minor (ensureRequestGroup), 1 verified unique-to-A minor (unstructured logging, corrected Rule 13 rationale), 1 verified unique-to-A info (double-cast, corrected line 99→107); no blocking issues.

Summary

Arbitration — round 1

Verified all 3 findings from A and the 1 finding from B.

Finding 1 (A + B agreed, transport.ts:172): Both reviewers independently flagged that runReviewViaTransport enqueues directly without calling ensureRequestGroup, while makeTransportLegRunner in distributed-quorum.ts:286 explicitly guards with that call first. Read confirms line 172 goes straight to enqueueReviewRequest; Grep confirms ensureRequestGroup is called at distributed-quorum.ts:286 and exported from transport.ts:213 but never invoked inside runReviewViaTransport. Kept as agreed minor.

Finding 2 (unique to A, module-bootstrap.ts:63+65): Read confirms both lines use console.log. Reviewer A's rationale cited "Rule 13 requires stderr" but Rule 13 actually states "App writes to stdout only (12-Factor XI)" — the rule mandates stdout, not stderr. The legitimate violation is the unstructured free-form template-string format, which contravenes Rule 13's "Structured logging (JSON or key-value) — no free-form strings" requirement. There is also an inconsistency with compare-modules.ts:75 which uses console.error. Kept as minor but rationale corrected: the fix should be structured JSON output to stdout, not a switch to console.error.

Finding 3 (unique to A, bin/compare-modules.ts): Read confirms the double-cast as unknown as ReviewJob exists at line 107 (not 99 as cited — 99 is the first field of the object literal). The cast is present and the concern about suppressed structural checking is valid. Corrected line to 107. Kept as info.

Blast Radius

The PR adds 22 files spanning a new distributed module topology (src/rmp/), two new binary entrypoints (bin/), a shared reconcile extracted from src/quorum.ts, six test suites, and a Docker Compose profile extension. The transport.ts export surface (runReviewViaTransport, makeTransportLegRunner, ensureRequestGroup) is consumed by multiple orchestration paths. Changes are additive and gated behind the rmp-modules Compose profile, limiting blast radius to opt-in deployments.

BLAST_SCORE: 6/10

Risk Indicators

Indicator Value
Sensitive functions ensureRequestGroup, enqueueReviewRequest, runReviewViaTransport, makeTransportLegRunner, runDistributedQuorum, collectModuleRun
Migration touched
Test delta +965 / -0 lines in test files
Dependency changes

CI status (head 062414e95196)

Overall: ✓ success

3 checks: 3 pending

Check State Link
pr-reviewer CI / tsc --noEmit (pull_request) pending details
pr-reviewer CI / tsc build (pull_request) pending details
pr-reviewer CI / vitest (pull_request) pending details

Findings (3)

[MINOR] runReviewViaTransport enqueues without the ensureRequestGroup cold-start guard

src/rmp/transport.ts:172

makeTransportLegRunner (distributed-quorum.ts:286) explicitly calls ensureRequestGroup(redis, keys, leg.moduleId) before enqueueReviewRequest, with the inline comment that the group must precede the enqueue for XREADGROUP > to deliver. runReviewViaTransport skips this step entirely — line 172 goes straight to enqueueReviewRequest.

In compare-modules.ts, Module A is driven exclusively via runReviewViaTransport({ …, moduleId: "claude-quorum" }). If the hib-pr-reviewer-module-quorum container restarts between the operator starting a compare run and this XADD, the re-created consumer group (XGROUP CREATE … $) places its cursor at or after the already-enqueued entry; XREADGROUP > will not deliver it. The event-read loop runs to maxIdleReads and compareModules records Module A as outcome: "silent" with no actionable error surface.

ensureRequestGroup is idempotent (BUSYGROUP is swallowed), so the fix is safe to add unconditionally:

// src/rmp/transport.ts — inside runReviewViaTransport, before line 172
await ensureRequestGroup(opts.redis, opts.keys, opts.moduleId);
await enqueueReviewRequest(opts.redis, opts.keys, opts.request, opts.moduleId);

This mirrors the pattern already established in makeTransportLegRunner.

[MINOR] Redis error handlers use unstructured free-form template strings (Rule 13 violation)

src/rmp/module-bootstrap.ts:63

Lines 63 and 65 attach Redis error handlers using console.log with template strings:

redis.on("error", (err: Error) => console.log(`[module] redis error: ${err.message}`));
subscriber.on("error", (err: Error) => console.log(`[module] subscriber error: ${err.message}`));

Rule 13 mandates structured logging — "Structured logging (JSON or key-value) — no free-form strings." Template-string messages are unstructured and cannot be reliably parsed, indexed, or alerted on by a log aggregator.

Note: Rule 13 also states "App writes to stdout only (12-Factor XI)," so the channel (console.log → stdout) is correct; the fix is to emit structured key-value or JSON objects, not to switch to console.error.

Also note the inconsistency: compare-modules.ts:75 uses console.error for its equivalent Redis error handler while module-bootstrap.ts uses console.log. Standardise on console.log (stdout) with structured output per Rule 13:

redis.on("error", (err: Error) =>
  console.log(JSON.stringify({ level: "error", module: "module-bootstrap", event: "redis_error", message: err.message }))
);

[INFO] as unknown as ReviewJob double-cast bypasses TypeScript's structural check

bin/compare-modules.ts:107

The job object literal constructed at lines 98–107 is escaped via as unknown as ReviewJob on the closing line. Test fixtures in tests/rmp-distributed-quorum.test.ts and tests/rmp-module-host.test.ts construct an identical shape and assign it directly as const JOB: ReviewJob = { … } without any cast, proving the literal satisfies the type.

The double-cast silently suppresses TypeScript's structural check at this site. If ReviewJob gains a required field in the future, this site will compile cleanly but throw at runtime.

Replace with a direct typed assignment:

const job: ReviewJob = {
  repoOwner: owner,
  repoName: repo,
  prNumber,
  prHeadSha: pr.head.sha,
  prBaseBranch: pr.base.ref,
  prTitle: pr.title,
  trigger: "opened",
  enqueuedAt: new Date().toISOString(),
};

Verdict

CONDITIONAL_APPROVE


hib-pr-reviewer • round 1 • 3 findings (2m/1i) • 2026-06-02T22:17:03.756Z → 2026-06-02T22:19:07.782Z • posted-as: pr-reviewer-bot • model: auto

<!-- hib-pr-reviewer round:1 --> ## hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer) **Round 1** — head `062414e95196`, base `main`, trigger `opened` **TL;DR:** CONDITIONAL_APPROVE — kept 1 agreed minor (ensureRequestGroup), 1 verified unique-to-A minor (unstructured logging, corrected Rule 13 rationale), 1 verified unique-to-A info (double-cast, corrected line 99→107); no blocking issues. ### Summary ## Arbitration — round 1 Verified all 3 findings from A and the 1 finding from B. **Finding 1** (A + B agreed, `transport.ts:172`): Both reviewers independently flagged that `runReviewViaTransport` enqueues directly without calling `ensureRequestGroup`, while `makeTransportLegRunner` in `distributed-quorum.ts:286` explicitly guards with that call first. `Read` confirms line 172 goes straight to `enqueueReviewRequest`; `Grep` confirms `ensureRequestGroup` is called at distributed-quorum.ts:286 and exported from transport.ts:213 but never invoked inside `runReviewViaTransport`. **Kept as agreed minor.** **Finding 2** (unique to A, `module-bootstrap.ts:63+65`): `Read` confirms both lines use `console.log`. Reviewer A's rationale cited "Rule 13 requires stderr" but Rule 13 actually states "App writes to stdout only (12-Factor XI)" — the rule mandates stdout, not stderr. The legitimate violation is the unstructured free-form template-string format, which contravenes Rule 13's "Structured logging (JSON or key-value) — no free-form strings" requirement. There is also an inconsistency with `compare-modules.ts:75` which uses `console.error`. Kept as minor but rationale corrected: the fix should be structured JSON output to stdout, not a switch to `console.error`. **Finding 3** (unique to A, `bin/compare-modules.ts`): `Read` confirms the double-cast `as unknown as ReviewJob` exists at line **107** (not 99 as cited — 99 is the first field of the object literal). The cast is present and the concern about suppressed structural checking is valid. Corrected line to 107. **Kept as info.** ### Blast Radius The PR adds 22 files spanning a new distributed module topology (src/rmp/), two new binary entrypoints (bin/), a shared reconcile extracted from src/quorum.ts, six test suites, and a Docker Compose profile extension. The transport.ts export surface (`runReviewViaTransport`, `makeTransportLegRunner`, `ensureRequestGroup`) is consumed by multiple orchestration paths. Changes are additive and gated behind the `rmp-modules` Compose profile, limiting blast radius to opt-in deployments. **BLAST_SCORE: 6/10** ### Risk Indicators | Indicator | Value | |---|---| | Sensitive functions | `ensureRequestGroup`, `enqueueReviewRequest`, `runReviewViaTransport`, `makeTransportLegRunner`, `runDistributedQuorum`, `collectModuleRun` | | Migration touched | — | | Test delta | +965 / -0 lines in test files | | Dependency changes | — | ### CI status (head `062414e95196`) **Overall: ✓ success** 3 checks: 3 pending | Check | State | Link | |---|---|---| | pr-reviewer CI / tsc --noEmit (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/60/jobs/0) | | pr-reviewer CI / tsc build (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/60/jobs/2) | | pr-reviewer CI / vitest (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/60/jobs/1) | ### Findings (3) #### **[MINOR]** `runReviewViaTransport` enqueues without the `ensureRequestGroup` cold-start guard _src/rmp/transport.ts:172_ `makeTransportLegRunner` (`distributed-quorum.ts:286`) explicitly calls `ensureRequestGroup(redis, keys, leg.moduleId)` **before** `enqueueReviewRequest`, with the inline comment that the group must precede the enqueue for `XREADGROUP >` to deliver. `runReviewViaTransport` skips this step entirely — line 172 goes straight to `enqueueReviewRequest`. In `compare-modules.ts`, Module A is driven exclusively via `runReviewViaTransport({ …, moduleId: "claude-quorum" })`. If the `hib-pr-reviewer-module-quorum` container restarts between the operator starting a compare run and this XADD, the re-created consumer group (`XGROUP CREATE … $`) places its cursor at or after the already-enqueued entry; `XREADGROUP >` will not deliver it. The event-read loop runs to `maxIdleReads` and `compareModules` records Module A as `outcome: "silent"` with no actionable error surface. `ensureRequestGroup` is idempotent (BUSYGROUP is swallowed), so the fix is safe to add unconditionally: ```typescript // src/rmp/transport.ts — inside runReviewViaTransport, before line 172 await ensureRequestGroup(opts.redis, opts.keys, opts.moduleId); await enqueueReviewRequest(opts.redis, opts.keys, opts.request, opts.moduleId); ``` This mirrors the pattern already established in `makeTransportLegRunner`. #### **[MINOR]** Redis error handlers use unstructured free-form template strings (Rule 13 violation) _src/rmp/module-bootstrap.ts:63_ Lines 63 and 65 attach Redis error handlers using `console.log` with template strings: ```typescript redis.on("error", (err: Error) => console.log(`[module] redis error: ${err.message}`)); subscriber.on("error", (err: Error) => console.log(`[module] subscriber error: ${err.message}`)); ``` Rule 13 mandates structured logging — "Structured logging (JSON or key-value) — no free-form strings." Template-string messages are unstructured and cannot be reliably parsed, indexed, or alerted on by a log aggregator. Note: Rule 13 also states "App writes to stdout only (12-Factor XI)," so the channel (`console.log` → stdout) is correct; the fix is to emit structured key-value or JSON objects, not to switch to `console.error`. Also note the inconsistency: `compare-modules.ts:75` uses `console.error` for its equivalent Redis error handler while `module-bootstrap.ts` uses `console.log`. Standardise on `console.log` (stdout) with structured output per Rule 13: ```typescript redis.on("error", (err: Error) => console.log(JSON.stringify({ level: "error", module: "module-bootstrap", event: "redis_error", message: err.message })) ); ``` #### **[INFO]** `as unknown as ReviewJob` double-cast bypasses TypeScript's structural check _bin/compare-modules.ts:107_ The `job` object literal constructed at lines 98–107 is escaped via `as unknown as ReviewJob` on the closing line. Test fixtures in `tests/rmp-distributed-quorum.test.ts` and `tests/rmp-module-host.test.ts` construct an identical shape and assign it directly as `const JOB: ReviewJob = { … }` without any cast, proving the literal satisfies the type. The double-cast silently suppresses TypeScript's structural check at this site. If `ReviewJob` gains a required field in the future, this site will compile cleanly but throw at runtime. Replace with a direct typed assignment: ```typescript const job: ReviewJob = { repoOwner: owner, repoName: repo, prNumber, prHeadSha: pr.head.sha, prBaseBranch: pr.base.ref, prTitle: pr.title, trigger: "opened", enqueuedAt: new Date().toISOString(), }; ``` ### Verdict **CONDITIONAL_APPROVE** --- <sub>hib-pr-reviewer • round 1 • 3 findings (2m/1i) • 2026-06-02T22:17:03.756Z → 2026-06-02T22:19:07.782Z • posted-as: pr-reviewer-bot • model: auto</sub>

Superseded by round 2.

Show previous round

hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer)

Round 1 — head 26fd8db7a2bf, base main, trigger synchronize

TL;DR: CONDITIONAL_APPROVE — kept 1 agreed finding (signal drop, A1+B1) and verified 3 unique findings (A2: latent hang from redundant guard; B2: double-cast removes type safety; B3: missing cold-start ensureRequestGroup in runReviewViaTransport); no blocking or major issues.

Summary

Arbitration summary

4 findings kept, 0 dropped.

  • A1 + B1 (AGREED, line 283): Both independently flag that makeTransportLegRunner omits the 4th signal parameter. Read of line 283 confirms return async (leg, request, emit) => { — signal silently dropped. LegRunner type at lines 56–61 declares signal: AbortSignal | undefined as the 4th arg. Kept.
  • A2 (unique to A, line 154): Inner guard if (survivor.ok && !dead.ok) is a tautology within the !outA.ok || !outB.ok branch (both-fail already returned at 141–148, survivor/dead are assigned to exactly the ok/not-ok sides). Read of lines 149–164 confirms the structure exactly as described; the unconditional bare return on line 163 creates a latent no-terminal-event hang if the guard were ever false after a refactor. Kept.
  • B2 (unique to B, line 107): as unknown as ReviewJob double-cast at bin/compare-modules.ts:107 confirmed by Read. Suppresses structural checking for future ReviewJob field additions. Kept.
  • B3 (unique to B, line 172): runReviewViaTransport at src/rmp/transport.ts:172 confirmed to call enqueueReviewRequest with no preceding ensureRequestGroup, while makeTransportLegRunner (line 286) explicitly guards with ensureRequestGroup first for cold-start safety. Asymmetry is real and can cause silent "silent" outcome on container restart. Kept.

Blast Radius

The diff is almost entirely additive (~1800 lines of new code across new files) behind the rmp-modules compose profile. The existing production review path (src/rmp/driver.ts, src/quorum.ts) is touched only minimally (driver: +17/-10, quorum: +11/-74 refactor into quorum-reconcile.ts). New exports from transport.ts, distributed-quorum.ts, and compare.ts extend the public RMP surface but are not yet wired into the serving path.

BLAST_SCORE: 4/10

Risk Indicators

Indicator Value
Sensitive functions runReviewViaTransport, makeTransportLegRunner, enqueueReviewRequest, ensureRequestGroup, publishCancel
Migration touched
Test delta +965 / -0 lines in test files
Dependency changes

CI status (head 26fd8db7a2bf)

Overall: ✓ success

3 checks: 3 pending

Check State Link
pr-reviewer CI / tsc --noEmit (pull_request) pending details
pr-reviewer CI / vitest (pull_request) pending details
pr-reviewer CI / tsc build (pull_request) pending details
  • #3 (live-validated cross-process run referenced in CHANGELOG)
  • #5 (mentioned as the validation PR in the CHANGELOG entry)

Findings (4)

[MINOR] makeTransportLegRunner silently drops signal — XREAD loop not cancellable on abort

src/rmp/distributed-quorum.ts:283

The LegRunner type (line 56–61) declares a 4th parameter signal: AbortSignal | undefined, but the closure returned by makeTransportLegRunner only destructures three:

// line 283
return async (leg, request, emit) => {   // signal dropped

The for await loop over readReviewEvents has no abort path; it only exits on a terminal event or maxIdleReads exhaustion. When the outer distributed quorum is cancelled (e.g. via AbortController on a server timeout), the transport leg will block for up to blockMs × maxIdleReads — in the CLI config that ceiling equals the full job timeout (20 min). The in-process runner (makeInProcessLegRunner) correctly forwards signal to runClaudeModule, so the two runners diverge on cancellation semantics.

Current impact is low (CLI path; process exit cleans up), but the broken API contract will silently hang if makeTransportLegRunner is wired into a long-lived server path.

Suggested fix: at minimum add abort awareness inside the loop:

return async (leg, request, emit, signal) => {
  // …
  for await (const event of readReviewEvents()) {
    if (signal?.aborted) return { ok: false, reason: 'cancelled' };
    const t = consumeLegEvent(event, leg.id, emit);
    if (t) terminal = t;
  }
  // …
};

Alternatively, publish a cancel on abort: signal?.addEventListener('abort', () => publishCancel(redis, keys, legRequest.requestId).catch(() => undefined)).

[MINOR] Redundant always-true inner guard in degradation branch creates a latent no-terminal hang path

src/rmp/distributed-quorum.ts:154

Inside the !outA.ok || !outB.ok branch (the both-fail case was already returned at lines 141–148), survivor is explicitly assigned the ok leg and dead the not-ok leg, so survivor.ok && !dead.ok at line 154 is a tautology and can never be false:

if (!outA.ok || !outB.ok) {          // exactly one failed
  const survivor = outA.ok ? outA : outB;
  const dead     = outA.ok ? outB : outA;
  const deadId   = outA.ok ? opts.legs[1].id : opts.legs[0].id;
  if (survivor.ok && !dead.ok) {     // ← always true
    emit({ t: 'degraded',  });
    finalize();
  }
  return;                            // reached regardless — finalize may be skipped
}

If the inner guard were ever false after a refactor (e.g. adding a third leg, changing the both-fail bail-out), finalize is skipped and the unconditional return exits with no terminal event emitted, causing the serving driver and compareModules to hang indefinitely.

Suggested fix: remove the inner if and call finalize unconditionally, adding an unreachable backstop:

if (!outA.ok || !outB.ok) {
  const survivor = outA.ok ? outA : outB;
  const deadId   = outA.ok ? opts.legs[1].id : opts.legs[0].id;
  const deadReason = (outA.ok ? outB : outA).reason;
  emit({ t: 'degraded', seq: 0, reason: `distributed leg ${deadId} failed (${deadReason}); proceeding on surviving leg`, leg: deadId });
  if (survivor.ok) {
    finalize(wrapIntoReviewResult(survivor.result, request, startedAt, now()));
  } else {
    emit({ t: 'failed', seq: 0, reason: 'distributed leg survivor unexpectedly not ok' });
  }
  return;
}

[MINOR] as unknown as ReviewJob double-cast is unnecessary and removes compile-time safety

bin/compare-modules.ts:107

The constructed literal satisfies every required ReviewJob field (trigger: "opened" is a valid union member; AllowedOwner = string; prBody is optional), yet the double-cast bypasses TypeScript's structural check:

} as unknown as ReviewJob;   // line 107

If ReviewJob gains a new required field, TypeScript will not flag the omission here. Replace with a typed binding to restore structural checking:

const job: ReviewJob = {
  repoOwner: owner,
  repoName: repo,
  prNumber,
  prHeadSha: pr.head.sha,
  prBaseBranch: pr.base.ref,
  prTitle: pr.title,
  trigger: "opened",
  enqueuedAt: new Date().toISOString(),
};

Or use satisfies ReviewJob if you want to keep the narrowed literal type.

[MINOR] runReviewViaTransport missing ensureRequestGroup — cold-start race can silently lose Module A's request

src/rmp/transport.ts:172

makeTransportLegRunner (the Module B leg path) explicitly calls ensureRequestGroup before enqueueReviewRequest with a // cold-start safety comment (lines 286–287). runReviewViaTransport (the Module A path in compare-modules.ts) does not:

// line 172 — no ensureRequestGroup guard
await enqueueReviewRequest(opts.redis, opts.keys, opts.request, opts.moduleId);

If the hib-pr-reviewer-module-quorum container has just restarted, the host's ensureRequestGroup runs with $ as the last-delivered-id after the already-enqueued message's stream ID. XREADGROUP > therefore never delivers the message, Module A produces outcome "silent" in the comparison report with no diagnostic, and the asymmetry with Module B is invisible.

Fix: add await ensureRequestGroup(opts.redis, opts.keys, opts.moduleId) immediately before line 172, mirroring makeTransportLegRunner's defensive pattern. The module containers have no healthcheck: in the compose file, so a depends_on: service_healthy gate is not currently available as a compensating control.

Verdict

CONDITIONAL_APPROVE


hib-pr-reviewer • round 1 • 4 findings (4m) • 2026-06-02T22:21:50.634Z → 2026-06-02T22:23:27.373Z • posted-as: pr-reviewer-bot • model: auto

<!-- hib-pr-reviewer collapsed --> > _Superseded by round 2._ <details> <summary>Show previous round</summary> <!-- hib-pr-reviewer round:1 --> ## hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer) **Round 1** — head `26fd8db7a2bf`, base `main`, trigger `synchronize` **TL;DR:** CONDITIONAL_APPROVE — kept 1 agreed finding (signal drop, A1+B1) and verified 3 unique findings (A2: latent hang from redundant guard; B2: double-cast removes type safety; B3: missing cold-start ensureRequestGroup in runReviewViaTransport); no blocking or major issues. ### Summary ## Arbitration summary **4 findings kept, 0 dropped.** - **A1 + B1 (AGREED, line 283):** Both independently flag that `makeTransportLegRunner` omits the 4th `signal` parameter. `Read` of line 283 confirms `return async (leg, request, emit) => {` — signal silently dropped. `LegRunner` type at lines 56–61 declares `signal: AbortSignal | undefined` as the 4th arg. Kept. - **A2 (unique to A, line 154):** Inner guard `if (survivor.ok && !dead.ok)` is a tautology within the `!outA.ok || !outB.ok` branch (both-fail already returned at 141–148, `survivor`/`dead` are assigned to exactly the ok/not-ok sides). `Read` of lines 149–164 confirms the structure exactly as described; the unconditional bare `return` on line 163 creates a latent no-terminal-event hang if the guard were ever false after a refactor. Kept. - **B2 (unique to B, line 107):** `as unknown as ReviewJob` double-cast at `bin/compare-modules.ts:107` confirmed by `Read`. Suppresses structural checking for future `ReviewJob` field additions. Kept. - **B3 (unique to B, line 172):** `runReviewViaTransport` at `src/rmp/transport.ts:172` confirmed to call `enqueueReviewRequest` with no preceding `ensureRequestGroup`, while `makeTransportLegRunner` (line 286) explicitly guards with `ensureRequestGroup` first for cold-start safety. Asymmetry is real and can cause silent `"silent"` outcome on container restart. Kept. ### Blast Radius The diff is almost entirely additive (~1800 lines of new code across new files) behind the `rmp-modules` compose profile. The existing production review path (`src/rmp/driver.ts`, `src/quorum.ts`) is touched only minimally (driver: +17/-10, quorum: +11/-74 refactor into quorum-reconcile.ts). New exports from `transport.ts`, `distributed-quorum.ts`, and `compare.ts` extend the public RMP surface but are not yet wired into the serving path. **BLAST_SCORE: 4/10** ### Risk Indicators | Indicator | Value | |---|---| | Sensitive functions | `runReviewViaTransport`, `makeTransportLegRunner`, `enqueueReviewRequest`, `ensureRequestGroup`, `publishCancel` | | Migration touched | — | | Test delta | +965 / -0 lines in test files | | Dependency changes | — | ### CI status (head `26fd8db7a2bf`) **Overall: ✓ success** 3 checks: 3 pending | Check | State | Link | |---|---|---| | pr-reviewer CI / tsc --noEmit (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/62/jobs/0) | | pr-reviewer CI / vitest (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/62/jobs/1) | | pr-reviewer CI / tsc build (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/62/jobs/2) | ### Related PRs - #3 (live-validated cross-process run referenced in CHANGELOG) - #5 (mentioned as the validation PR in the CHANGELOG entry) ### Findings (4) #### **[MINOR]** `makeTransportLegRunner` silently drops `signal` — XREAD loop not cancellable on abort _src/rmp/distributed-quorum.ts:283_ The `LegRunner` type (line 56–61) declares a 4th parameter `signal: AbortSignal | undefined`, but the closure returned by `makeTransportLegRunner` only destructures three: ```typescript // line 283 return async (leg, request, emit) => { // signal dropped ``` The `for await` loop over `readReviewEvents` has no abort path; it only exits on a terminal event or `maxIdleReads` exhaustion. When the outer distributed quorum is cancelled (e.g. via `AbortController` on a server timeout), the transport leg will block for up to `blockMs × maxIdleReads` — in the CLI config that ceiling equals the full job timeout (20 min). The in-process runner (`makeInProcessLegRunner`) correctly forwards `signal` to `runClaudeModule`, so the two runners diverge on cancellation semantics. Current impact is low (CLI path; process exit cleans up), but the broken API contract will silently hang if `makeTransportLegRunner` is wired into a long-lived server path. **Suggested fix:** at minimum add abort awareness inside the loop: ```typescript return async (leg, request, emit, signal) => { // … for await (const event of readReviewEvents(…)) { if (signal?.aborted) return { ok: false, reason: 'cancelled' }; const t = consumeLegEvent(event, leg.id, emit); if (t) terminal = t; } // … }; ``` Alternatively, publish a cancel on abort: `signal?.addEventListener('abort', () => publishCancel(redis, keys, legRequest.requestId).catch(() => undefined))`. #### **[MINOR]** Redundant always-true inner guard in degradation branch creates a latent no-terminal hang path _src/rmp/distributed-quorum.ts:154_ Inside the `!outA.ok || !outB.ok` branch (the both-fail case was already returned at lines 141–148), `survivor` is explicitly assigned the ok leg and `dead` the not-ok leg, so `survivor.ok && !dead.ok` at line 154 is a tautology and can never be false: ```typescript if (!outA.ok || !outB.ok) { // exactly one failed const survivor = outA.ok ? outA : outB; const dead = outA.ok ? outB : outA; const deadId = outA.ok ? opts.legs[1].id : opts.legs[0].id; if (survivor.ok && !dead.ok) { // ← always true emit({ t: 'degraded', … }); finalize(…); } return; // reached regardless — finalize may be skipped } ``` If the inner guard were ever false after a refactor (e.g. adding a third leg, changing the both-fail bail-out), `finalize` is skipped and the unconditional `return` exits with no terminal event emitted, causing the serving driver and `compareModules` to hang indefinitely. **Suggested fix:** remove the inner `if` and call `finalize` unconditionally, adding an unreachable backstop: ```typescript if (!outA.ok || !outB.ok) { const survivor = outA.ok ? outA : outB; const deadId = outA.ok ? opts.legs[1].id : opts.legs[0].id; const deadReason = (outA.ok ? outB : outA).reason; emit({ t: 'degraded', seq: 0, reason: `distributed leg ${deadId} failed (${deadReason}); proceeding on surviving leg`, leg: deadId }); if (survivor.ok) { finalize(wrapIntoReviewResult(survivor.result, request, startedAt, now())); } else { emit({ t: 'failed', seq: 0, reason: 'distributed leg survivor unexpectedly not ok' }); } return; } ``` #### **[MINOR]** `as unknown as ReviewJob` double-cast is unnecessary and removes compile-time safety _bin/compare-modules.ts:107_ The constructed literal satisfies every required `ReviewJob` field (`trigger: "opened"` is a valid union member; `AllowedOwner = string`; `prBody` is optional), yet the double-cast bypasses TypeScript's structural check: ```typescript } as unknown as ReviewJob; // line 107 ``` If `ReviewJob` gains a new required field, TypeScript will not flag the omission here. Replace with a typed binding to restore structural checking: ```typescript const job: ReviewJob = { repoOwner: owner, repoName: repo, prNumber, prHeadSha: pr.head.sha, prBaseBranch: pr.base.ref, prTitle: pr.title, trigger: "opened", enqueuedAt: new Date().toISOString(), }; ``` Or use `satisfies ReviewJob` if you want to keep the narrowed literal type. #### **[MINOR]** `runReviewViaTransport` missing `ensureRequestGroup` — cold-start race can silently lose Module A's request _src/rmp/transport.ts:172_ `makeTransportLegRunner` (the Module B leg path) explicitly calls `ensureRequestGroup` before `enqueueReviewRequest` with a `// cold-start safety` comment (lines 286–287). `runReviewViaTransport` (the Module A path in `compare-modules.ts`) does not: ```typescript // line 172 — no ensureRequestGroup guard await enqueueReviewRequest(opts.redis, opts.keys, opts.request, opts.moduleId); ``` If the `hib-pr-reviewer-module-quorum` container has just restarted, the host's `ensureRequestGroup` runs with `$` as the last-delivered-id *after* the already-enqueued message's stream ID. `XREADGROUP >` therefore never delivers the message, Module A produces outcome `"silent"` in the comparison report with no diagnostic, and the asymmetry with Module B is invisible. **Fix:** add `await ensureRequestGroup(opts.redis, opts.keys, opts.moduleId)` immediately before line 172, mirroring `makeTransportLegRunner`'s defensive pattern. The module containers have no `healthcheck:` in the compose file, so a `depends_on: service_healthy` gate is not currently available as a compensating control. ### Verdict **CONDITIONAL_APPROVE** --- <sub>hib-pr-reviewer • round 1 • 4 findings (4m) • 2026-06-02T22:21:50.634Z → 2026-06-02T22:23:27.373Z • posted-as: pr-reviewer-bot • model: auto</sub> </details>
Round-1 review (CONDITIONAL_APPROVE, 3 findings):
- MINOR (transport.ts): runReviewViaTransport now ensures the request group
  BEFORE enqueue (cold-start safety — a module recreating its group at `$`
  after the XADD would miss the request → Module A silent). Mirrors
  makeTransportLegRunner. + a regression test (the request is now claimable).
- MINOR (module-bootstrap.ts): Redis error handlers emit structured JSON
  (component/event/message) per rule 13, not free-form template strings.
- INFO (compare-modules.ts): the Redis error log is structured + consistent;
  the ReviewJob literal is a direct typed assignment (AllowedOwner = string),
  dropping the `as unknown as ReviewJob` double-cast.
fix(rmp): address PR #4 round-2 — honor abort signal + guarantee a terminal
All checks were successful
pr-reviewer CI / tsc --noEmit (pull_request) Successful in 22s
pr-reviewer CI / vitest (pull_request) Successful in 1m0s
pr-reviewer CI / tsc build (pull_request) Successful in 34s
2cf595082f
Round-2 review surfaced two more reliability findings:
- MINOR: makeTransportLegRunner dropped the abort `signal`, so a cancelled
  review couldn't stop the leg's XREAD loop. readReviewEvents now takes an
  optional `signal` (checked between reads → cancellation lands within one
  blockMs window); makeTransportLegRunner forwards it. + a regression test.
- MINOR: the one-leg-degrade branch's single `if (survivor.ok && !dead.ok)`
  guard could `return` with NO terminal if it were ever false (a silent driver
  hang). Restructured into explicit one-survivor branches with a defensive
  `failed` terminal on the unreachable path, so every path emits a terminal.

Superseded by round 3.

Show previous round

hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer)

Round 2 — head 2cf595082fb2, base main, trigger synchronize

TL;DR: CONDITIONAL_APPROVE — 3 minor findings kept this round.

Summary

Arbiter reconciled 1 (A) + 3 (B) → 3 findings.

Risk Indicators

Indicator Value
Sensitive functions
Migration touched
Test delta +996 / -0 lines in test files
Dependency changes

CI status (head 2cf595082fb2)

Overall: ✓ success

3 checks: 3 pending

Check State Link
pr-reviewer CI / tsc --noEmit (pull_request) pending details
pr-reviewer CI / vitest (pull_request) pending details
pr-reviewer CI / tsc build (pull_request) pending details

Findings (3)

[MINOR] runner: ModuleReviewer required in DistributedLegSpec forces a double-cast stub for the transport-leg case

src/rmp/distributed-quorum.ts:45

Line 45: runner: ModuleReviewer; is non-optional. The inline JSDoc already concedes "may be a no-op stub there" for transport legs, yet the type forces callers to supply something. bin/compare-modules.ts:123-125 works around it with:

const stubRunner = {
  run: () => Promise.reject(new Error("transport leg ignores the in-process runner")),
} as unknown as ModuleReviewer;

The as unknown as double-cast silences the compiler without enforcing correctness. If a future caller omits legRunner and falls through to the in-process path, stubRunner.run() returns an opaque rejected promise — hard to diagnose.

Fix:

  1. src/rmp/distributed-quorum.ts:45 — change runner: ModuleReviewer;runner?: ModuleReviewer;.
  2. Add a null-guard at the top of makeInProcessLegRunner where leg.runner is invoked:
    if (!leg.runner) return { ok: false, reason: `leg ${leg.id}: no in-process runner provided` };
    
  3. bin/compare-modules.ts — replace runner: stubRunner with runner: undefined (or omit the field) and delete the stubRunner constant.

No runtime behaviour change; the guard converts a silent thrown promise into a legible LegOutcome failure and removes the need for the unsafe cast.

[MINOR] "both-failed" reliability verdict fires only on exact failed+failed; silent+silent (or silent+failed) misroutes to "one-failed"

src/rmp/compare.ts:230

Lines 230-232 at HEAD:

if (a.outcome === "failed" && b.outcome === "failed") reliabilityVerdict = "both-failed";
else if (a.outcome !== "result" || b.outcome !== "result") reliabilityVerdict = "one-failed";

When both legs time out / produce no terminal (outcome "silent"), the first guard is false (neither is "failed") and the second guard fires — yielding "one-failed" for a total-outage scenario. An operator reading the headline headline sees one failure when both topologies produced nothing.

Fix — key on "neither produced a verdict" rather than the exact "failed" token:

if (a.outcome !== "result" && b.outcome !== "result") reliabilityVerdict = "both-failed";
else if (a.outcome !== "result" || b.outcome !== "result") reliabilityVerdict = "one-failed";

This maps failed+failed, silent+silent, and failed+silent all to "both-failed", matching semantic intent.

[MINOR] Startup, signal, and stop logs are free-form template strings — violates rule 13 (structured JSON logging)

src/rmp/module-bootstrap.ts:100

Lines 100-103, 106, and 115 emit unstructured template literals:

console.log(
  `[module] ${descriptor.id} v${descriptor.version} starting — consumer=${consumer} stream=${stream} …`,
);
// …
console.log(`[module] ${sig} — draining in-flight then exiting`);
// …
console.log(`[module] ${descriptor.id} stopped cleanly`);

Rule 13 (.claude/rules/13-logging-observability.md) requires structured logging (JSON or key-value) with no free-form strings. The NAS json-file driver captures this container's stdout verbatim, and the Redis-error handlers in the same file already use JSON.stringify. These three paths are inconsistent.

Concrete fix:

console.log(JSON.stringify({
  component: "module", event: "starting",
  id: descriptor.id, version: descriptor.version,
  consumer, stream,
  memora: memoraMcpUrl ? "on" : "off",
  intel: codeIntelligenceRoot ? "on" : "off",
  sdkCap: sdkLimit,
}));
// …
console.log(JSON.stringify({ component: "module", event: "signal", signal: sig }));
// …
console.log(JSON.stringify({ component: "module", event: "stopped", id: descriptor.id }));

Verdict

CONDITIONAL_APPROVE


hib-pr-reviewer • round 2 • 3 findings (3m) • 2026-06-02T22:35:08.801Z → 2026-06-02T22:36:30.691Z • posted-as: pr-reviewer-bot • model: auto • [bookkeeping fallback]

<!-- hib-pr-reviewer collapsed --> > _Superseded by round 3._ <details> <summary>Show previous round</summary> <!-- hib-pr-reviewer round:2 --> ## hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer) **Round 2** — head `2cf595082fb2`, base `main`, trigger `synchronize` **TL;DR:** CONDITIONAL_APPROVE — 3 minor findings kept this round. ### Summary Arbiter reconciled 1 (A) + 3 (B) → 3 findings. ### Risk Indicators | Indicator | Value | |---|---| | Sensitive functions | — | | Migration touched | — | | Test delta | +996 / -0 lines in test files | | Dependency changes | — | ### CI status (head `2cf595082fb2`) **Overall: ✓ success** 3 checks: 3 pending | Check | State | Link | |---|---|---| | pr-reviewer CI / tsc --noEmit (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/63/jobs/0) | | pr-reviewer CI / vitest (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/63/jobs/1) | | pr-reviewer CI / tsc build (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/63/jobs/2) | ### Findings (3) #### **[MINOR]** `runner: ModuleReviewer` required in `DistributedLegSpec` forces a double-cast stub for the transport-leg case _src/rmp/distributed-quorum.ts:45_ Line 45: `runner: ModuleReviewer;` is non-optional. The inline JSDoc already concedes *"may be a no-op stub there"* for transport legs, yet the type forces callers to supply something. `bin/compare-modules.ts:123-125` works around it with: ```ts const stubRunner = { run: () => Promise.reject(new Error("transport leg ignores the in-process runner")), } as unknown as ModuleReviewer; ``` The `as unknown as` double-cast silences the compiler without enforcing correctness. If a future caller omits `legRunner` and falls through to the in-process path, `stubRunner.run()` returns an opaque rejected promise — hard to diagnose. Fix: 1. `src/rmp/distributed-quorum.ts:45` — change `runner: ModuleReviewer;` → `runner?: ModuleReviewer;`. 2. Add a null-guard at the top of `makeInProcessLegRunner` where `leg.runner` is invoked: ```ts if (!leg.runner) return { ok: false, reason: `leg ${leg.id}: no in-process runner provided` }; ``` 3. `bin/compare-modules.ts` — replace `runner: stubRunner` with `runner: undefined` (or omit the field) and delete the `stubRunner` constant. No runtime behaviour change; the guard converts a silent thrown promise into a legible `LegOutcome` failure and removes the need for the unsafe cast. #### **[MINOR]** `"both-failed"` reliability verdict fires only on exact `failed`+`failed`; `silent`+`silent` (or `silent`+`failed`) misroutes to `"one-failed"` _src/rmp/compare.ts:230_ Lines 230-232 at HEAD: ```ts if (a.outcome === "failed" && b.outcome === "failed") reliabilityVerdict = "both-failed"; else if (a.outcome !== "result" || b.outcome !== "result") reliabilityVerdict = "one-failed"; ``` When both legs time out / produce no terminal (outcome `"silent"`), the first guard is false (neither is `"failed"`) and the second guard fires — yielding `"one-failed"` for a total-outage scenario. An operator reading the headline headline sees one failure when both topologies produced nothing. Fix — key on "neither produced a verdict" rather than the exact `"failed"` token: ```ts if (a.outcome !== "result" && b.outcome !== "result") reliabilityVerdict = "both-failed"; else if (a.outcome !== "result" || b.outcome !== "result") reliabilityVerdict = "one-failed"; ``` This maps `failed+failed`, `silent+silent`, and `failed+silent` all to `"both-failed"`, matching semantic intent. #### **[MINOR]** Startup, signal, and stop logs are free-form template strings — violates rule 13 (structured JSON logging) _src/rmp/module-bootstrap.ts:100_ Lines 100-103, 106, and 115 emit unstructured template literals: ```ts console.log( `[module] ${descriptor.id} v${descriptor.version} starting — consumer=${consumer} stream=${stream} …`, ); // … console.log(`[module] ${sig} — draining in-flight then exiting`); // … console.log(`[module] ${descriptor.id} stopped cleanly`); ``` Rule 13 (`.claude/rules/13-logging-observability.md`) requires structured logging (JSON or key-value) with no free-form strings. The NAS `json-file` driver captures this container's stdout verbatim, and the Redis-error handlers in the same file already use `JSON.stringify`. These three paths are inconsistent. Concrete fix: ```ts console.log(JSON.stringify({ component: "module", event: "starting", id: descriptor.id, version: descriptor.version, consumer, stream, memora: memoraMcpUrl ? "on" : "off", intel: codeIntelligenceRoot ? "on" : "off", sdkCap: sdkLimit, })); // … console.log(JSON.stringify({ component: "module", event: "signal", signal: sig })); // … console.log(JSON.stringify({ component: "module", event: "stopped", id: descriptor.id })); ``` ### Verdict **CONDITIONAL_APPROVE** --- <sub>hib-pr-reviewer • round 2 • 3 findings (3m) • 2026-06-02T22:35:08.801Z → 2026-06-02T22:36:30.691Z • posted-as: pr-reviewer-bot • model: auto • [bookkeeping fallback]</sub> </details>
fix(rmp): address PR #4 round-3 — optional leg runner, silent=failure, structured logs
All checks were successful
pr-reviewer CI / tsc --noEmit (pull_request) Successful in 22s
pr-reviewer CI / vitest (pull_request) Successful in 59s
pr-reviewer CI / tsc build (pull_request) Successful in 35s
ea9c956eef
Round-3 review (3 minor):
- DistributedLegSpec.runner is now optional (the transport leg-runner ignores
  it; makeInProcessLegRunner guards a missing runner → {ok:false}), removing the
  `as unknown as ModuleReviewer` stub double-cast in compare-modules.ts + the
  transport test.
- compare.ts reliability verdict: a `silent` outcome (no terminal) now counts as
  a failure, so both-silent → "both-failed" (was misclassified "one-failed").
  + a test.
- module-bootstrap.ts startup/signal/stop logs emit structured JSON (rule 13),
  matching the redis-error handlers.
hibryda deleted branch feat/rmp-quorum-modules 2026-06-03 00:43:18 +02:00

hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer)

Round 3 — head ea9c956eef72, base main, trigger synchronize

TL;DR: CONDITIONAL_APPROVE — 1 agreed finding (A+B, module-host.ts free-form log calls) and 1 verified unique-to-A finding (compare-modules.ts stderr progress strings); both minor rule-13 violations, no blocking issues.

Summary

Arbitration — Round 3

Actions taken:

  1. Read /var/lib/pr-reviewer/repos/hib/hib-pr-reviewer/1526/src/rmp/module-host.ts in full — verified lines 52, 79, 87, 116, 132 exactly as both A and B described.
  2. Read /var/lib/pr-reviewer/repos/hib/hib-pr-reviewer/1526/bin/compare-modules.ts in full — verified lines 82, 88, 139, 160 exactly as A described.

Finding 1 (module-host.ts): Both A and B flag the same file and same four call-sites (79, 87, 116, 132) for free-form template-literal log calls. Agreed finding — kept. A and B cite different primary line anchors (A→79, B→52). Both are accurate: line 52 is the log?: (message: string) => void type declaration that enables the violation; lines 79/87/116/132 are the actual violation call-sites. Merged into one finding anchored at line 52 (root cause) with all four call-sites documented. B's additional recommendation to change the type signature and update module-bootstrap.ts:97 is incorporated as it makes the fix more complete.

Finding 2 (compare-modules.ts): Unique to A. Lines 82, 88, 139, 160 all confirmed to contain the exact free-form console.error(\...`)` template literals A cited. Rule 13 violation confirmed. Unique-to-A finding verified — kept.

Result: 1 agreed finding, 1 unique-to-A verified finding — both minor. No findings dropped.

Blast Radius

The PR adds a substantial new subsystem (23 files, ~2000 LOC) spanning src/rmp/, bin/, deploy/, and tests. However the CHANGELOG explicitly states the engine's normal review path is untouched, and the new RMP module infrastructure is behind a rmp-modules compose profile. The two log-format violations are confined to a daemon consume-loop and an operator CLI tool respectively — neither touches the core review pipeline.

BLAST_SCORE: 4/10

Risk Indicators

Indicator Value
Sensitive functions registerModule, subscribeCancellations, ensureRequestGroup, ackRequest, claimNextRequest, runReviewViaTransport, runDistributedQuorum
Migration touched
Test delta +1000 / -0 lines in test files
Dependency changes

CI status (head ea9c956eef72)

Overall: ✓ success

3 checks: 3 pending

Check State Link
pr-reviewer CI / tsc --noEmit (pull_request) pending details
pr-reviewer CI / vitest (pull_request) pending details
pr-reviewer CI / tsc build (pull_request) pending details

Findings (2)

[MINOR] Four log(…) call-sites emit free-form template strings — rule 13 violation in the long-lived daemon (agreed: A+B)

src/rmp/module-host.ts:52

The ModuleHostOpts.log type at line 52 is (message: string) => void, and all four operational log call-sites pass plain template literals:

// line 79
log(`[module-host:${moduleId}] re-register failed: ${err instanceof Error ? err.message : err}`);
// line 87
log(`[module-host:${moduleId}] cancel ${requestId}`);
// line 116
log(`[module-host:${moduleId}] emit failed (${request.requestId}): ${err instanceof Error ? err.message : err}`);
// line 132
log(`[module-host:${moduleId}] ack failed (${streamId}): ${err instanceof Error ? err.message : err}`);

module-bootstrap.ts wires this as log: (m) => console.log(m) (line 97), so all four paths land as opaque strings in the docker json-file log driver — a rule 13 violation ("no free-form strings"). This is inconsistent with every other log site in module-bootstrap.ts, which was fixed in round 2 to emit JSON.stringify({…}).

Recommended fix (three-part):

  1. Change the type at line 52:
log?: (message: Record<string, unknown>) => void;
  1. Replace each call-site:
// line 79
log({ component: "module-host", event: "reregister-failed", moduleId, message: err instanceof Error ? err.message : String(err) });
// line 87
log({ component: "module-host", event: "cancel", moduleId, requestId });
// line 116
log({ component: "module-host", event: "emit-failed", moduleId, requestId: request.requestId, message: err instanceof Error ? err.message : String(err) });
// line 132
log({ component: "module-host", event: "ack-failed", moduleId, streamId, message: err instanceof Error ? err.message : String(err) });
  1. Update module-bootstrap.ts line 97:
log: (m) => console.log(JSON.stringify(m)),

[MINOR] Four console.error(…) progress lines use free-form template strings — rule 13 violation (unique-to-A, verified)

bin/compare-modules.ts:82

Lines 82, 88, 139, and 160 write unstructured template literals to stderr:

console.error(`[compare] fetching ${owner}/${repo}#${prNumber} (round ${round})…`);           // line 82
console.error(`[compare] staging PR-HEAD checkout (${pr.head.sha.slice(0, 7)})…`);           // line 88
console.error(`[compare] module-A terminal: ${err instanceof Error ? err.message : err}`);    // line 139
console.error(`[compare] running module A (quorum-in-module) then B (distributed-quorum)…`); // line 160

Rule 13 requires structured (JSON or key-value) output — no free-form strings. The redis-error path at line 73 already correctly uses console.log(JSON.stringify({…})), making the inconsistency clear. The error path at line 139 is the highest-impact site: a module-A failure landing as an opaque string cannot be filtered or alerted on by log tooling.

Recommended fix:

// line 82
console.error(JSON.stringify({ component: "compare", event: "fetching", owner, repo, prNumber, round }));
// line 88
console.error(JSON.stringify({ component: "compare", event: "staging", sha: pr.head.sha.slice(0, 7) }));
// line 139
console.error(JSON.stringify({ component: "compare", event: "module-a-terminal", message: err instanceof Error ? err.message : String(err) }));
// line 160
console.error(JSON.stringify({ component: "compare", event: "running-modules" }));

Note: all four lines also write to stderr instead of stdout, which conflicts with 12-Factor XI ("app writes to stdout only; environment handles routing"). Consider switching to console.log with the structured JSON form above.

Verdict

CONDITIONAL_APPROVE


hib-pr-reviewer • round 3 • 2 findings (2m) • 2026-06-02T22:44:56.704Z → 2026-06-02T22:46:10.836Z • posted-as: pr-reviewer-bot • model: auto

<!-- hib-pr-reviewer round:3 --> ## hib-pr-reviewer review — PR #4 (hib/hib-pr-reviewer) **Round 3** — head `ea9c956eef72`, base `main`, trigger `synchronize` **TL;DR:** CONDITIONAL_APPROVE — 1 agreed finding (A+B, module-host.ts free-form log calls) and 1 verified unique-to-A finding (compare-modules.ts stderr progress strings); both minor rule-13 violations, no blocking issues. ### Summary ## Arbitration — Round 3 **Actions taken:** 1. Read `/var/lib/pr-reviewer/repos/hib/hib-pr-reviewer/1526/src/rmp/module-host.ts` in full — verified lines 52, 79, 87, 116, 132 exactly as both A and B described. 2. Read `/var/lib/pr-reviewer/repos/hib/hib-pr-reviewer/1526/bin/compare-modules.ts` in full — verified lines 82, 88, 139, 160 exactly as A described. **Finding 1 (module-host.ts):** Both A and B flag the same file and same four call-sites (79, 87, 116, 132) for free-form template-literal log calls. **Agreed finding — kept.** A and B cite different primary line anchors (A→79, B→52). Both are accurate: line 52 is the `log?: (message: string) => void` type declaration that enables the violation; lines 79/87/116/132 are the actual violation call-sites. Merged into one finding anchored at line 52 (root cause) with all four call-sites documented. B's additional recommendation to change the type signature and update `module-bootstrap.ts:97` is incorporated as it makes the fix more complete. **Finding 2 (compare-modules.ts):** Unique to A. Lines 82, 88, 139, 160 all confirmed to contain the exact free-form `console.error(\`...\`)` template literals A cited. Rule 13 violation confirmed. **Unique-to-A finding verified — kept.** **Result:** 1 agreed finding, 1 unique-to-A verified finding — both minor. No findings dropped. ### Blast Radius The PR adds a substantial new subsystem (23 files, ~2000 LOC) spanning `src/rmp/`, `bin/`, `deploy/`, and tests. However the CHANGELOG explicitly states the engine's normal review path is untouched, and the new RMP module infrastructure is behind a `rmp-modules` compose profile. The two log-format violations are confined to a daemon consume-loop and an operator CLI tool respectively — neither touches the core review pipeline. **BLAST_SCORE: 4/10** ### Risk Indicators | Indicator | Value | |---|---| | Sensitive functions | `registerModule`, `subscribeCancellations`, `ensureRequestGroup`, `ackRequest`, `claimNextRequest`, `runReviewViaTransport`, `runDistributedQuorum` | | Migration touched | — | | Test delta | +1000 / -0 lines in test files | | Dependency changes | — | ### CI status (head `ea9c956eef72`) **Overall: ✓ success** 3 checks: 3 pending | Check | State | Link | |---|---|---| | pr-reviewer CI / tsc --noEmit (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/64/jobs/0) | | pr-reviewer CI / vitest (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/64/jobs/1) | | pr-reviewer CI / tsc build (pull_request) | ⏳ pending | [details](/hib/hib-pr-reviewer/actions/runs/64/jobs/2) | ### Findings (2) #### **[MINOR]** Four `log(…)` call-sites emit free-form template strings — rule 13 violation in the long-lived daemon (agreed: A+B) _src/rmp/module-host.ts:52_ The `ModuleHostOpts.log` type at line 52 is `(message: string) => void`, and all four operational log call-sites pass plain template literals: ```ts // line 79 log(`[module-host:${moduleId}] re-register failed: ${err instanceof Error ? err.message : err}`); // line 87 log(`[module-host:${moduleId}] cancel ${requestId}`); // line 116 log(`[module-host:${moduleId}] emit failed (${request.requestId}): ${err instanceof Error ? err.message : err}`); // line 132 log(`[module-host:${moduleId}] ack failed (${streamId}): ${err instanceof Error ? err.message : err}`); ``` `module-bootstrap.ts` wires this as `log: (m) => console.log(m)` (line 97), so all four paths land as opaque strings in the docker `json-file` log driver — a rule 13 violation ("no free-form strings"). This is inconsistent with every other log site in `module-bootstrap.ts`, which was fixed in round 2 to emit `JSON.stringify({…})`. **Recommended fix (three-part):** 1. Change the type at line 52: ```ts log?: (message: Record<string, unknown>) => void; ``` 2. Replace each call-site: ```ts // line 79 log({ component: "module-host", event: "reregister-failed", moduleId, message: err instanceof Error ? err.message : String(err) }); // line 87 log({ component: "module-host", event: "cancel", moduleId, requestId }); // line 116 log({ component: "module-host", event: "emit-failed", moduleId, requestId: request.requestId, message: err instanceof Error ? err.message : String(err) }); // line 132 log({ component: "module-host", event: "ack-failed", moduleId, streamId, message: err instanceof Error ? err.message : String(err) }); ``` 3. Update `module-bootstrap.ts` line 97: ```ts log: (m) => console.log(JSON.stringify(m)), ``` #### **[MINOR]** Four `console.error(…)` progress lines use free-form template strings — rule 13 violation (unique-to-A, verified) _bin/compare-modules.ts:82_ Lines 82, 88, 139, and 160 write unstructured template literals to stderr: ```ts console.error(`[compare] fetching ${owner}/${repo}#${prNumber} (round ${round})…`); // line 82 console.error(`[compare] staging PR-HEAD checkout (${pr.head.sha.slice(0, 7)})…`); // line 88 console.error(`[compare] module-A terminal: ${err instanceof Error ? err.message : err}`); // line 139 console.error(`[compare] running module A (quorum-in-module) then B (distributed-quorum)…`); // line 160 ``` Rule 13 requires structured (JSON or key-value) output — no free-form strings. The redis-error path at line 73 already correctly uses `console.log(JSON.stringify({…}))`, making the inconsistency clear. The error path at line 139 is the highest-impact site: a module-A failure landing as an opaque string cannot be filtered or alerted on by log tooling. **Recommended fix:** ```ts // line 82 console.error(JSON.stringify({ component: "compare", event: "fetching", owner, repo, prNumber, round })); // line 88 console.error(JSON.stringify({ component: "compare", event: "staging", sha: pr.head.sha.slice(0, 7) })); // line 139 console.error(JSON.stringify({ component: "compare", event: "module-a-terminal", message: err instanceof Error ? err.message : String(err) })); // line 160 console.error(JSON.stringify({ component: "compare", event: "running-modules" })); ``` Note: all four lines also write to stderr instead of stdout, which conflicts with 12-Factor XI ("app writes to stdout only; environment handles routing"). Consider switching to `console.log` with the structured JSON form above. ### Verdict **CONDITIONAL_APPROVE** --- <sub>hib-pr-reviewer • round 3 • 2 findings (2m) • 2026-06-02T22:44:56.704Z → 2026-06-02T22:46:10.836Z • posted-as: pr-reviewer-bot • model: auto</sub>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
hib/hib-pr-reviewer!4
No description provided.