feat(sandcastle): add multi-critic ensemble with voted merge and parallel execution#1866
feat(sandcastle): add multi-critic ensemble with voted merge and parallel execution#1866jerome-benoit wants to merge 85 commits into
Conversation
Extend LoopStrategy with optional fields driving an N-critic ensemble per refinement round: - criticCount, criticModels, criticEfforts (per-slot) - criticAgreementThreshold, criticFillStrategy, criticEnsembleSeed - criticArbiterModel/Effort/PromptFile (optional stage-2 arbiter) Extend Finding with optional ensemble metadata (votes, voters, disagreementScore, contested) populated by the merge function. Extend RoundSnapshot with optional validCriticCount. Add CriticSlot interface for resolved per-slot invocation. Add canonical defaults: AGENT_CRITIC_COUNT=1, AGENT_CRITIC_MODELS, MAX_CRITIC_COUNT=8, CRITIC_AGREEMENT_FRACTION=0.5, CRITIC_FILL_STRATEGY_DEFAULT='round-robin'. All fields are additive and optional; behavior is unchanged when no ensemble fields are set on a strategy. Refs: arXiv:2406.04692 (MoA), arXiv:2404.18796 (PoLL).
Pure module exposing the cross-critic deduplication and voted merge:
- resolveCriticSlots: backward-compat single-slot for legacy strategies;
round-robin or seeded random-with-replacement fill when criticCount
exceeds criticModels.length.
- findingDedupKey: file::normalizeCategory(category)::contextHash;
severity intentionally absent so the same defect flagged at different
severities aggregates into one merged finding.
- normalizeCategory: lowercase + strip non-alphanumerics so phrasing
variants ('sql-injection', 'SQL Injection', 'SQLInjection') vote
together.
- mergeCriticFindings: majority-vote with median-tie-up severity and
confidence aggregation; singleton CRITICAL+HIGH-confidence escape
hatch (cap at HIGH, contested=true) preserves rare findings without
granting unilateral CRITICAL veto power.
- disagreementScore: variance of severity ranks normalized by 9/4 of
the 4-level ordinal ladder, in [0, 1]; calibration-free uncertainty
signal preferred over LLM textual confidence labels.
Title/description/suggestion are picked from the lowest-slot voter
(deterministic, bias-free) rather than the longest-description voter,
which would amplify the verbosity bias documented in arXiv:2306.05685
across all LLM judges including GPT-4.
Tests cover backward compat (N=1 identity), round-robin fill, seeded
random fill reproducibility, severity median tie-up, cross-critic
dedup with category-phrasing variance, singleton-CRITICAL escape with
HIGH cap, disagreement scoring, and ordering invariants.
Refs: arXiv:2306.05685, arXiv:2207.05221, arXiv:2406.04692.
Refactor the critic phase to fan out N slots in parallel via Promise.allSettled with per-slot single parse-retry (mirroring legacy single-critic semantics), enforce a simple-majority quorum, then call mergeCriticFindings with precomputed line-context hashes. Quorum: when fewer than ceil(N/2) slots return parseable findings the round is marked critic_errored. The threshold is calibrated for crash faults; LLM critics fail by timeout/parse failure/OOM, not adversarial collusion, so BFT thresholds (Castro & Liskov, OSDI 1999) are not warranted. Per-slot nonces keep stdout streams unambiguous. Each critic runs with its own model+effort resolved from the slot list. The signal is propagated to all slots so cooperative cancellation kills every in-flight critic. Optional stage-2 arbiter: when criticArbiterModel and criticArbiterPromptFile are both set, after merge the arbiter receives the per-critic outputs and the merged list and emits a refined list. Triggered only when the merged list contains at least one HIGH or CRITICAL finding to bound cost. Failure is non-fatal: returns the merge result unchanged on parse failure or sandbox error. Backward compat: when no ensemble fields are set, resolveCriticSlots returns a single legacy slot; the loop runs one sandbox.run with one parse-retry (byte-equivalent to the prior single-critic path). Carries validCriticCount upward into RoundSnapshot for observability. Refs: arXiv:2406.04692 (MoA stage-2 aggregator pattern).
Extend STRATEGY_REGISTRY indexing with fail-fast validation of every
optional ensemble field, mirroring the existing STRATEGY_KEY_PATTERN
and CONTROL_TAG_PATTERN guards: misconfiguration throws a field-named
error at module load before any sandbox is spawned.
Rules:
- criticCount must be an integer in [1, MAX_CRITIC_COUNT].
- criticModels (when set) must be a non-empty array of non-blank
strings.
- criticEfforts (when array) length must equal criticModels.length.
- criticAgreementThreshold (when number) must be an integer in
[1, criticCount].
- criticFillStrategy='random-with-replacement' requires a numeric
criticEnsembleSeed for reproducibility.
- criticArbiterModel and criticArbiterPromptFile must be set
together.
Expose validateLoopStrategyEnsemble for unit tests with a
caller-supplied error context label.
Tests cover: 17 cases including the legacy-single-critic accept path,
each rejection path, scalar criticEfforts broadcast, both arbiter
fields set together, and seed-required random fill.
Add README covering the orchestrator module map and the multi-critic ensemble: strategy fields, slot resolution, parallel execution model, quorum policy, merge algorithm (dedup key, median tie-up severity, singleton-CRITICAL escape, disagreementScore), convergence and quality-ratchet semantics, registry-load validation rules, cost model, and SOTA references. Documents the deliberate divergence from industry SAST severity aggregation (CVSS v3.1 mandates MAX, DefectDojo preserves first-seen): median tie-up is more robust to outlier LLM critics, and the singleton-CRITICAL escape hatch covers asymmetric-cost cases without granting unilateral CRITICAL veto power to any single critic.
SummaryThe following content is AI-generated and provides a summary of the pull request: Add Multi-Critic Ensemble with Voted Merge and Parallel ExecutionNew Feature✨ Extends the The design addresses the single-point-of-failure in code review quality by running up to 8 heterogeneous critic models simultaneously and aggregating their findings via ordinal severity aggregation, cross-critic deduplication, and quorum gating — drawing on LLM ensemble research (PoLL, MoA, Self-Consistency). Changes
PR Bot InformationVersion:
|
There was a problem hiding this comment.
Summary
The PR introduces a well-structured multi-critic ensemble with parallel execution, majority voting, and deterministic merge semantics. The core design is sound and backward-compatible. Several substantive issues were identified:
-
Floating-point precision loss in
deterministicIndex— combining two UInt32 values viahigh * 0x100000000 + lowexceedsNumber.MAX_SAFE_INTEGER, making the modulo result unreliable and breaking the reproducibility guarantee. Should useBigInt. -
criticEffortsvalidation uses wrong upper bound invalidateLoopStrategyEnsemble— whencriticCountis set withoutcriticModels, the expected length falls back to1instead ofcriticCount, causing valid configurations to be wrongly rejected and invalid ones accepted. -
Per-slot effort array mismatch in
resolveCriticSlots—resolvePerSlotEffortsis called withcount(total fill-extended slots) but is validated againstcriticModels.length, so fill-extension slots silently lose their declared per-model effort and fall back to the scalarcriticEffort. -
Docstring incorrectly says "sample variance" for
severityDisagreementScore— the implementation correctly uses population variance; the mislabeling could cause confusion when the formula is compared to textbook references. -
Missing abort guard before retry in
runOneCritic— if the abort signal fires after a parse failure but before the retrysandbox.run, the slot wastes a full idle timeout before the parentrunCriticcan propagate the abort.
PR Bot Information
Version: 1.20.51
- Event Trigger:
pull_request.opened - Correlation ID:
1abf1399-ffb5-44e1-a760-c7562d5faf13 - Agent Instructions:
- File Content Strategy: Full file content
- LLM:
anthropic--claude-4.6-sonnet
There was a problem hiding this comment.
Pull request overview
Extends the .sandcastle/ actor↔critic refinement loop to support a multi-critic ensemble per round, running critics in parallel and merging their findings via deterministic voting + deduplication, with optional stage-2 arbiter refinement and strategy-level fail-fast validation.
Changes:
- Added ensemble configuration fields to strategy/types and canonical defaults (count/models/efforts/quorum/arbiter).
- Implemented ensemble merge logic (
resolveCriticSlots, dedup keying, majority voting, severity/confidence aggregation, disagreement scoring). - Refactored the refinement loop to run critics in parallel, enforce quorum, merge outputs, and (optionally) run an arbiter; added documentation and unit tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
tests/sandcastle/strategies-validation.test.ts |
Unit tests for ensemble-field validation errors and backward compatibility. |
tests/sandcastle/merge-findings.test.ts |
Unit tests for slot resolution and deterministic merge/vote behavior. |
.sandcastle/types.ts |
Adds ensemble strategy fields and new optional merged-finding metadata fields. |
.sandcastle/constants.ts |
Introduces ensemble-related canonical defaults and limits (e.g., MAX_CRITIC_COUNT). |
.sandcastle/merge-findings.ts |
New pure module implementing slot resolution and voted merge/dedup/aggregation. |
.sandcastle/refinement-loop.ts |
Runs multiple critics in parallel, merges results, and optionally invokes an arbiter. |
.sandcastle/strategies/index.ts |
Adds registry-load validation for ensemble configuration + exports validator for tests. |
.sandcastle/README.md |
Documents module map and ensemble semantics/validation/cost model. |
Comments suppressed due to low confidence (1)
.sandcastle/refinement-loop.ts:792
runOneCriticderivesnonceas${baseNonce}-c${slot.index}, butparseFindingsrejects any nonce that isn’t strictly hex (/^[0-9a-f]+$/). This makes parsing fail for every slot (including the retry), so all critics will return null and quorum will typically fail. Use a hex-only per-slot nonce (e.g., derive a hex digest from baseNonce+slotIndex) or changeparseFindingsto accept/escape the nonce format safely.
* @param baseNonce - Round-level nonce; per-slot nonce is `${baseNonce}-c{slot.index}`.
* @param slot - Resolved critic slot (model + effort + index).
* @returns Parsed findings, or null on definitive parse failure.
*/
async function runOneCritic (
ctx: LoopContext,
round: number,
baseNonce: string,
slot: CriticSlot
): Promise<Finding[] | null> {
const { baseBranch, sandbox, signal, spec, strategy } = ctx
const nonce = `${baseNonce}-c${String(slot.index)}`
let critic = await sandbox.run({
agent: agentProvider(slot.model, slot.effort),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: 1,
name: `Critic #${spec.id} R${String(round)} C${String(slot.index)}`,
promptArgs: { ...strategy.buildCriticArgs(spec, baseBranch), NONCE: nonce },
promptFile: strategy.criticPromptFile,
signal,
})
…el field
The introduction of AGENT_CRITIC_MODELS (plural) and the criticModels
field in this change makes the singular AGENT_CRITIC_MODEL constant
and the criticModel strategy field redundant. Per the codebase's
single-source-of-truth and naming-coherence principles (AGENTS.md),
retain only the plural names.
- Drop the singular AGENT_CRITIC_MODEL constant; declare
AGENT_CRITIC_MODELS as a tuple literal (`as const`) so
AGENT_CRITIC_MODELS[0] is statically guaranteed non-undefined.
- Drop the LoopStrategy.criticModel field; strategies that need a
single critic now declare criticModels: ['model-id'].
- Simplify resolveCriticSlots: the previous triple-fallback chain
collapses to a binary check now that AGENT_CRITIC_MODELS is
non-empty by construction.
- Update tests and docs accordingly; the 'legacy fallback' test
becomes 'default slot from constants' covering the same
!ensembleConfigured branch via AGENT_CRITIC_MODELS[0].
No production strategy in STRATEGY_REGISTRY uses the legacy criticModel
field, so this is a no-op on production behavior. Net diff: -9 lines.
…critic pool, and arbiter
Replace parallel actorModel/actorEffort and criticModels/criticEfforts
fields with a single canonical AgentSpec value type that bundles model
and reasoning effort atomically. Effort is REQUIRED on AgentSpec: the
right effort is a property of the model, so silent role-wide effort
fallbacks would defeat the purpose of the pairing.
Field shape:
- actor: AgentSpec (single, optional)
- criticPool: readonly [AgentSpec, ...AgentSpec[]] (non-empty tuple)
- arbiter: { agent: AgentSpec; promptFile: string } (struct, optional)
Compile-time guarantees from the new type shape eliminate three classes
of runtime failures present in the previous design:
1. Parallel-array desync: reordering criticModels no longer silently
misaligns criticEfforts because each pool entry carries its own
effort. Structurally impossible.
2. Empty critic pool: encoded by the non-empty tuple type. Runtime
length-zero check redundant.
3. Arbiter half-set: encoded by the struct. The XOR 'set together'
cross-field validation rule is gone.
Defaults migrate from scalar AGENT_ACTOR_MODEL/EFFORT and
AGENT_CRITIC_MODELS/EFFORT to AGENT_ACTOR_DEFAULT and
AGENT_CRITIC_POOL_DEFAULT, both of AgentSpec shape, restoring single-
source-of-truth per AGENTS.md DRY principle.
Validation rules collapsed: 8 ad-hoc field checks (count range, models
empty, efforts length match, efforts elements, arbiter XOR, etc.) into
5 structural checks (delegating to validateAgentSpec for any role).
BREAKING CHANGE: LoopStrategy fields actorModel, actorEffort,
criticModels, criticEfforts, criticEffort, criticArbiterModel,
criticArbiterEffort, criticArbiterPromptFile are removed. Use the new
AgentSpec-based fields instead. Internal-only module: the sole
production strategy (implementStrategy) relies on defaults and is
unaffected.
Refs: AGENTS.md DRY / single-source-of-truth / naming-coherence.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (2)
.sandcastle/refinement-loop.ts:569
- The
perCriticOutputsparam doc says "null entries excluded", butrunCriticpasses the rawperCriticOutputsarray including nulls (and you stringify it as-is). Either filter out nulls before passing to the arbiter, or update the docstring/arbiter prompt expectations accordingly.
* @param perCriticOutputs - Raw per-slot findings (null entries excluded).
* @param merged - Merged finding list from `mergeCriticFindings`.
.sandcastle/merge-findings.ts:207
resolveCriticSlotsdefaultscounttomax(AGENT_CRITIC_COUNT, pool.length), which means setting a multi-entrycriticPoolwithout also settingcriticCountwill run all pool entries by default. This conflicts with the docs/PR narrative thatcriticCountdefaults to 1, and it can be a surprising cost increase. Consider defaultingcountstrictly toAGENT_CRITIC_COUNT(1) unlesscriticCountis explicitly set, or update the documentation to clearly state the pool-length-driven default.
const pool = strategy.criticPool ?? AGENT_CRITIC_POOL_DEFAULT
const count = strategy.criticCount ?? Math.max(AGENT_CRITIC_COUNT, pool.length)
const fillStrategy = strategy.criticFillStrategy ?? CRITIC_FILL_STRATEGY_DEFAULT
Complete the AgentSpec unification across all four agent roles. Two
roles were still on the legacy split-scalar pattern after the previous
refactor:
- Planner (used in task-source.ts for issue triage) was wired to
AGENT_PLANNER_MODEL + AGENT_PLANNER_EFFORT scalars.
- Arbiter had no AgentSpec default at all; opt-in strategies had to
declare the agent inline.
Both now derive from canonical AgentSpec constants:
- AGENT_PLANNER_DEFAULT: AgentSpec
- AGENT_ARBITER_DEFAULT: AgentSpec
task-source.ts uses AGENT_PLANNER_DEFAULT.model / .effort. Strategies
opting into the arbiter spread AGENT_ARBITER_DEFAULT into arbiter.agent
to inherit the canonical default while keeping the struct's
both-fields-required-when-set type guarantee.
Result: every agent invocation in .sandcastle/ now sources its (model,
effort) pair from the same atomic AgentSpec shape, restoring the
single-source-of-truth principle uniformly. The legacy
AGENT_PLANNER_MODEL and AGENT_PLANNER_EFFORT scalars are removed; no
external consumers reference them.
…ostic architecture
The previous README was multi-critic-ensemble-focused and didn't make
clear that .sandcastle/ is a generic actor↔critic refinement framework
whose kernel knows nothing about issue-to-PR (or any task type).
Strategies are pluggable; only the registered `implement` strategy
binds the kernel to GitHub issue resolution today.
Restructured per state-of-the-art conventions for agent-loop
documentation (LangGraph capability-first lead, SWE-agent one-action
quickstart, CrewAI 'using with AI coding agents' meta-section).
New sections, in order:
- What it is — kernel + strategies, ephemeral sandbox.
- How it works — ASCII flow diagram (filling a gap none of the four
surveyed reference projects address in their READMEs).
- Lifecycle (per nightly run) — discovery → fan-out → loop → finalize,
8 numbered sub-steps for the loop body.
- Module map — per-file purpose with explicit 'strategy-aware?'
column making the kernel/strategy boundary visible.
- Core concepts — the 8 vocabulary terms used throughout the module.
- Adding a new strategy — 4 concrete steps; emphasizes the kernel
is unchanged.
- Multi-critic ensemble — condensed; preserves SOTA-divergence note.
- Robustness mechanisms — every defensive code path (ratchet, best-
state, parse retry, quorum, mid/post-loop validation, push rescue,
sandbox abort) with file:function references.
- Configuration — agent defaults, loop tunables, env vars in three
grouped tables.
- Sandboxing — Docker lifecycle, hooks, pnpm-store mount.
- Running — pnpm sandcastle + CI cron schedule.
- Testing — how to run the 41 unit tests.
- Using with AI coding agents — extension contract for Copilot/
Claude reading this README.
- References — primary sources cited inline in code.
All documented constant values, file paths, command strings, and test
counts cross-checked against the source. Length: 214 lines.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
.sandcastle/refinement-loop.ts:569
- The
maybeRunArbiterdocstring still refers tocriticArbiterModel/criticArbiterPromptFile, but the code gates onstrategy.arbiter. Also,perCriticOutputsis documented as having null entries excluded, but the function serializes the array including nulls (JSON.stringify(perCriticOutputs, ...)). Update the comment (or filter nulls before passing) so the documentation matches the actual behavior and current field names.
/**
* Optional stage-2 arbiter pass (MoA pattern, arXiv:2406.04692). Triggered
* only when `criticArbiterModel`/`criticArbiterPromptFile` are both set AND
* the merged list contains at least one HIGH or CRITICAL finding. Failure
* is non-fatal: returns the unrefined merged list on any error.
* @param ctx - Loop context.
* @param round - Current round number.
* @param baseNonce - Round-level nonce; arbiter nonce is `${baseNonce}-arbiter`.
* @param perCriticOutputs - Raw per-slot findings (null entries excluded).
* @param merged - Merged finding list from `mergeCriticFindings`.
…dedicated scripts
The sandcastle orchestrator's tests were nested at tests/sandcastle/,
matched by the repo-wide `pnpm test` glob, and not invocable
independently. They now live next to the module they exercise, under
.sandcastle/tests/, and are run by a dedicated `pnpm test:sandcastle`
script — physically and logically untangled from the main app test
suite.
Module is now self-contained at the test layer too: production code
(.sandcastle/*.ts) and tests (.sandcastle/tests/*.test.ts) sit
together, with the same self-imposed boundary as before — zero
imports from src/, ui/, or tests/helpers/.
Changes:
- Move tests/sandcastle/{merge-findings,strategies-validation}.test.ts
to .sandcastle/tests/ and adjust relative imports (../../.sandcastle/
becomes ../).
- Add three scripts mirroring the app pattern:
test:sandcastle, test:sandcastle:debug, test:sandcastle:coverage.
Coverage targets .sandcastle/**/*.ts and writes lcov to
.sandcastle/coverage/lcov.info.
- Add coverage/ to .sandcastle/.gitignore.
- Add the test:sandcastle and test:sandcastle:coverage steps to the
Build simulator job in .github/workflows/ci.yml (the root-level
job; the three sub-project jobs use their own working-directory
package.json and are unaffected).
- Update .sandcastle/README.md Testing section with the new commands
and a note about the deliberate separation.
Test count conservation: `pnpm test` drops from 2448 to 2407, and
`pnpm test:sandcastle` runs the missing 41. No test removed, no test
duplicated. tsconfig.json, eslint scope, and prettier scope already
cover .sandcastle/ — no tooling changes required.
…age, properties)
Apply the 12 consensus recommendations from the cross-validated test
audit (3 oracles + cross-validation). Suite grows from 41 tests
covering only `merge-findings.ts` and `validateLoopStrategyEnsemble`
to 95 tests covering kernel pure helpers, infrastructure primitives,
finalization arg-builder, registry validators, partial-recovery JSON
parser, and property-based invariants on the most algorithmically
intricate module.
Production extractions (kept signatures; minimal-diff refactors):
- parseFindings → .sandcastle/parse-findings.ts (was file-private in
refinement-loop.ts; pure regex+JSON, security-relevant nonce guard).
- checkEarlyExit, shouldResetToBest, buildRoundSnapshot,
resolveLoopOptions → .sandcastle/loop-control.ts (was
file-private; pure decision logic).
- isValidSha(s) → .sandcastle/utils.ts (replaces 3 inline
/^[0-9a-f]{40}$/ regex sites in refinement-loop.ts).
- StrategyValidationError extends Error { readonly field: string }
exported from strategies/index.ts; validators migrated to throw it.
- validateRegistryEntries(entries) exported from strategies/index.ts;
factored out of indexByKey to enable direct testing of the four
fail-fast rules (key pattern, controlTag pattern, duplicate keys,
prefix-overlap).
New test files (under .sandcastle/tests/):
- factories.ts — shared fixtures (spec, fakeFinding,
fakeStrategy, baseStrategy, ctxHashesFor,
asInvalidPool, fakeSpec, fakeLoopResult).
- utils.test.ts — 3 tests for isValidSha.
- parse-findings.test.ts — 5 tests for nonce-tagged JSON extraction.
- loop-control.test.ts — 14 tests across the 4 extracted helpers.
- concurrency-pool.test.ts — 7 tests (RangeError, FIFO, release-on-
reject, peak-bound under burst).
- finalizer.test.ts — 8 tests for buildPrArgs.
- types.test.ts — 3 tests for parseFindingsSafe partial recovery.
- merge-findings.property.test.ts — 5 fast-check property tests.
Renamed and rewritten:
- strategies-validation.test.ts → strategies.test.ts (consolidates
validateLoopStrategyEnsemble + validateRegistryEntries under one
describe('strategies'); migrates ALL assert.throws(/regex/) to
assert.throws(err => err instanceof StrategyValidationError &&
err.field === '...') per AGENTS.md typed-error convention).
Test data robustness:
- 6 tests in merge-findings.test.ts now set severity / confidence
explicitly where dedup behavior was implicitly coupled to
fakeFinding factory defaults (audit recommendation).
Coverage (sandcastle): all targeted modules at 100% lines / 100% funcs.
Aggregate: 96.91% lines, 94.01% branches, 94.91% funcs.
refinement-loop.ts and task-source.ts orchestration code remain
outside unit-test coverage (3/3 audit anti-recommendation: validate
end-to-end via the existing nightly Docker run, not via mocked
Sandbox, which would couple tests to @ai-hero/sandcastle internals).
Dependencies: added fast-check ^4.x as devDep.
Tests: 41 → 95 (+54). Build clean. Typecheck clean. Lint 0 errors.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 24 changed files in this pull request and generated 4 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
.sandcastle/refinement-loop.ts:664
- Per-slot
nonceis${baseNonce}-c${slot.index}, butparseFindingsonly accepts lowercase-hex nonces. This causes every critic parse to fail (both attempts), which will cascade into quorum failures and effectively disable the critic phase. Derive per-slot nonces using hex-only characters (e.g., append a hex digit/prefix) or adjustparseFindingsto safely handle non-hex nonces.
const { baseBranch, sandbox, signal, spec, strategy } = ctx
const nonce = `${baseNonce}-c${String(slot.index)}`
…edup-key parity) Apply the cross-validated remediations from the three-oracle PR audit on PR #1866. The headline fix (P0) restores the multi-critic ensemble to functional state — the runtime per-slot and arbiter nonces (`<base>-c<idx>`, `<base>-arbiter`) were rejected by `parseFindings`'s strict pure-hex regex, making every critic and arbiter invocation return null in production. The remaining changes fix four latent correctness bugs and harden the type model. P0 — release blocker (consensus 2/3 oracles): - `parse-findings.ts` — nonce alphabet relaxed from `/^[0-9a-f]+$/` to `/^[0-9a-z][0-9a-z-]{0,63}$/`. Lowercase letters and hyphens are not regex metacharacters when interpolated outside a character class, so the constructed tag regex stays injection-safe; bounded length defeats catastrophic backtracking. Existing test which asserted hyphens are rejected was contradicting runtime; rewritten as alphabet-violation test covering uppercase, dot, regex meta, leading hyphen, length cap. Two new regression tests lock the runtime shapes `cafe1234-c0` and `cafe1234-arbiter`. P0 — missing safety net (consensus 3/3 oracles): - `tests/refinement-loop.test.ts` (new) — kernel integration tests with a `SandboxInstance` stub. 11 cases cover `runOneCritic` (parse OK, fresh-suffix retry, both-fail null), `runCritic` (quorum OK with merged votes, quorum fail, single-slot legacy identity), and `maybeRunArbiter` (HIGH/CRITICAL trigger, default-agent inheritance, LOW/MED skip, parse-fail merged passthrough, throw merged passthrough, arbiter-undefined skip). `runOneCritic`, `runCritic`, `maybeRunArbiter` exported with "Exported for kernel integration tests; not part of the public API" annotation. P1 — correctness fixes: - `refinement-loop.ts` (B1) — `bestSha` tracker switched from `newFindings.length` (post-dedup against `seenKeys`, monotone-shrinking metric biased toward late rounds) to `nonLowFindings.length` (absolute quality, matches the README contract "track the SHA at the round with fewest non-LOW findings"). - `refinement-loop.ts` (B2) — `computeFindingKey` now applies `normalizeCategory` to its category component so cross-round dedup uses the same key space as `findingDedupKey` (cross-critic merge). Without this, a finding flagged at round N as `"sql-injection"` and re-emitted at round N+1 as `"SQLInjection"` would survive dedup and drive spurious quality-ratchet rollbacks. `normalizeCategory` exported from `merge-findings.ts` so both sites consume one implementation. - `refinement-loop.ts` (B3) — `AGENT_ARBITER_DEFAULT` wired as the canonical default for arbiter agents. Strategies now declare `arbiter: { promptFile: '...' }` and the kernel resolves the agent as `strategy.arbiter.agent ?? AGENT_ARBITER_DEFAULT`. Type `LoopStrategy.arbiter.agent` is now optional. The constant is no longer dead code; the README spread-default claim is now accurate. - `refinement-loop.ts` (B4) — `deduplicateFindings` rewritten zip-based over the parallel `keys` array. Drops the O(n²) `findings.indexOf(f)` look-up and the reference-equality footgun (`indexOf` returns the first match, which would mis-key reference-equal duplicates). - `refinement-loop.ts` + `types.ts` (B5) — `AbortSignal` now propagated through `LoopStrategy.validate`. Signature widened to `(cwd, spec, signal?) => Promise<boolean>`; the kernel default closure forwards `signal` from outer scope; all three call sites pass the signal so strategy overrides honor abort. Without this, the 15-min `VALIDATION_TIMEOUT_MS` could outlive a task abort. - `strategies/index.ts` (B6) — `validateLoopStrategyEnsemble` now rejects `criticPool.length > MAX_CRITIC_COUNT` (was previously only enforced on `criticCount`). Closes the documented contract gap where a strategy could ship a 9-entry pool and bypass the cost ceiling. Test added. - `types.ts` (B7) — `RoundSnapshot.validCriticCount` JSDoc corrected. The field is telemetry only; the previous claim that it is consulted by the quality ratchet was documentation drift (the ratchet uses absolute non-LOW finding counts). P2 — type & docs hygiene: - `README.md` — qualitative description of test coverage replaces the stale "41 unit tests" count (suite is now 111). - `refinement-loop.ts` — `maybeRunArbiter` JSDoc refreshed: stale `criticArbiterModel`/`criticArbiterPromptFile` reference replaced with `strategy.arbiter` (the post-unification field). - `types.ts` + `refinement-loop.ts` — `LoopResult.failureReason` typed as the string-literal union `'actor_error' | 'critic_parse_failed' | 'quality_regression'` (was `string`). - `refinement-loop.ts` — `runOneCritic` retry now uses a fresh `-r1` suffix on the second attempt's nonce so the agent sees a different tag envelope; locked by integration test #2. - `merge-findings.ts` — `severityDisagreementScore` JSDoc corrected (population variance, not sample; `Math.min(1, …)` clamp explained). P3 — cosmetic: - `merge-findings.ts` — `contested` now via conditional spread (`...(!aboveThreshold && { contested: true })`) instead of unconditional `contested: false` on every above-threshold finding. - `README.md` — module map adds `loop-control.ts` and `parse-findings.ts`. Tests: 95 → 111 (+16). All quality gates green: `pnpm typecheck`, `pnpm lint` (0 errors, 4 cspell baseline warnings), `pnpm test:sandcastle` (111/111), `pnpm test` (2401/2401, 6 pre-existing skips), `pnpm build`.
…finalize-signal) Apply the cross-validated round-2 audit consensus on PR #1866. The previous remediation (commit a0a2f34) closed all round-1 findings; this commit closes the gaps the round-2 oracles surfaced — one P0 correctness bug single-flagged at HIGH (the line-less-findings dedup-key parity gap that escaped the round-1 'normalizeCategory' fix), six 2/3-consensus P1 issues, and three 2/3-consensus P2 cleanups. P0 — single-oracle HIGH (correctness): - SO1 — `computeFindingKey` no-line branch produced a third key segment derived from a normalized title hash, while `findingDedupKey` for the same line-less finding produced a third segment derived from `fallbackHash(file, '_')`. Cross-round and cross-critic dedup therefore disagreed on every finding without `line`. Round-1's C16 fix only aligned the second segment (`category` via `normalizeCategory`); the third segment is now also aligned by extracting `noLineFallbackHash(file)` in `merge-findings.ts` and having both consumers call it. New tests assert key parity on line-less findings (`tests/merge-findings.test.ts`). P1 — consensus 2/3 oracles: - CV1 / B1 — `runRefinementLoop` end-to-end coverage. New `describe('runRefinementLoop end-to-end')` block in `tests/refinement-loop.test.ts` uses a real temp git repo (no mocked `execFileAsync`) plus a `SandboxInstance` stub that materializes `git commit --allow-empty` for actor calls and tagged `<findings>` stdout for critic calls. First case locks the post-loop retry success path: validate fails for 3 main rounds → status='exhausted', post-loop validate passes → status='converged' AND failureReason cleared (locks CV5 invariant). - CV2 — Arbiter silently disabled when `slots.length === 1`. The N=1 short-circuit in `runCritic` previously bypassed both `mergeCriticFindings` and `maybeRunArbiter`; with the default single-entry pool, a strategy enabling `arbiter` got no arbitration silently. Lifted out: when N=1, skip the merge but still run the arbiter against the sole critic's output. Test in `tests/refinement-loop.test.ts` asserts the second `sandbox.run` call carries the `-arbiter` nonce. - CV3 — `AbortSignal` not propagated through `finalize`. Widened `FinalizationConfig.finalize` to `(spec, loopResult, sandbox, signal?)`, threaded `ac.signal` from `main.ts`, forwarded to both `runValidation` calls, `attemptRebase`, `pushBranch`, and the `gh pr create` execFileAsync in `strategies/implement/strategy.ts`. Without this, finalize could spend up to 30 minutes (2 × `VALIDATION_TIMEOUT_MS`) past the task abort. - CV4 — Post-loop retry round bookkeeping inconsistent. `roundsCompleted` is now incremented when the retry round runs, `bestSha`/`bestFindingsCount` are updated against the retry's non-LOW count, and the corresponding `LoopResult.roundsCompleted` JSDoc is corrected to include the retry round. Locked by the new end-to-end test asserting `totalCommits ≥ 3` after a 3-round loop + retry path. - CV5 — `failureReason` invariants and ensemble taxonomy. Added `'critic_quorum_failed'` to the union; `checkEarlyExit` now returns a discriminating `failureReason` based on `result.validCriticCount` (0 → parse-failed, >0 → quorum-failed, commits=0 → actor-error). The main loop consumes the discriminated value instead of computing it inline. Post-loop retry resets `failureReason = undefined` on every `status='converged'` flip so `LoopResult`'s 'reason for non-converged termination' invariant holds. New `checkEarlyExit` tests in `tests/loop-control.test.ts` (3 cases) lock each branch. - CV6 — `maybeRunArbiter` JSDoc claimed null entries excluded; code passed them through `JSON.stringify` to the arbiter prompt. Filtered at the `runCritic` call site (now passing `validOutputs` of type `Finding[][]`); narrowed parameter to `readonly Finding[][]` so the type system enforces the contract. Test asserts `PER_CRITIC_FINDINGS` prompt arg never contains the literal string 'null'. - SO2 — `criticCount < criticPool.length` silently truncated the pool. `validateLoopStrategyEnsemble` now rejects the combination with field `{ctx}.criticCount` so the violation surfaces at module load instead of silently dropping pool entries. Test added. P2 — consensus 2/3 oracles: - CV7 — Removed unreachable `if (result.findings === null) break` after `checkEarlyExit` (which already exits on null findings). The type narrowing now flows from `?? []` instead of an explicit pre-check; behavior unchanged. - CV8 — Replaced the default `validate` fallback closure with the direct `runValidation` reference. The closure form silently dropped its third `signal` arg in favor of an outer-scope capture; both arguments resolve to the same value today, but the closure form quietly defeated callers that passed a different signal. - CV9 — `signal?.throwIfAborted()` added between `runOneCritic`'s first attempt and its retry sandbox.run, so an abort that fires after the first parse-failure short-circuits before the retry spends another sandbox call. P3 — single-oracle cleanup: - SO4 — `loop-control.ts:shouldResetToBest` was duplicating the 40-hex SHA regex inline; now imports and uses `isValidSha` from `utils.ts` (DRY). Tests: 111 → 120 (+9). Quality gates: `pnpm typecheck`, `pnpm lint` (0 errors, 5 cspell baseline warnings), `pnpm test:sandcastle` (120/120), `pnpm test` (2401/2401, 6 pre-existing skips), `pnpm build`. Out of scope (deferred to follow-up, per round-2 plan): `ReasoningEffort` local type to decouple `AgentSpec.effort` from `PiOptions['thinking']` (SO3); README marketing-prose trim; critic/arbiter `maxIterations: 1` exposure; trigger-label inheritance on PRs; remaining single-oracle nits not in the consensus list.
Summary
Add a multi-critic ensemble to
.sandcastle/with parallel critic execution, voted merge, optional arbiter synthesis, severity-aware DoS bounds, and backward-compatible N=1 behavior.LoopStrategyadditionscriticPoolreadonly [AgentSpec, ...AgentSpec[]]AGENT_CRITIC_POOL_DEFAULT{ model, effort }pairs.criticCountnumbermax(AGENT_CRITIC_COUNT, criticPool.length)MAX_CRITIC_COUNT = 8.criticAgreementThreshold⌈validCount × CRITIC_AGREEMENT_FRACTION⌉criticFillStrategyround-robincriticEnsembleSeednumberrandom-with-replacementfill.arbiter{ agent?: AgentSpec; promptFile: string }Behavior
Promise.allSettled; each slot keeps one parse retry with retry nonce${nonce}-r1.validCount >= ceil(resolvedCriticCount / 2).${file}::${normalizeCategory(category)}::${ctxHash(line)}. Severity is intentionally absent so the same defect at different severities aggregates.title/description/suggestioncome from the lowest-slot voter.voters[]preserves original critic-slot indices even when middle slots parse-fail.severity ∈ {HIGH, CRITICAL}andconfidence = HIGH;contested = trueis set and merged severity is clamped to[MEDIUM, HIGH]. Same-slot duplicate findings preserve escape-qualified evidence within a critic slot.CRITIC_ESCAPE_CAP_PER_SLOT.StrategyValidationErrorbefore any sandbox is spawned.FindingSchemaenforces per-field bounds andparseFindingstruncates each critic payload toMAX_FINDINGS_PER_CRITIC = 200by severity-aware stable sort.See
.sandcastle/README.mdfor the full operational specification.Files
.sandcastle/types.ts,.sandcastle/constants.ts.sandcastle/merge-findings.ts.sandcastle/refinement-loop.ts,.sandcastle/loop-control.ts.sandcastle/parse-findings.ts.sandcastle/errors.ts(SandcastleError extends Errorwith discriminantcode).sandcastle/strategies/index.ts.sandcastle/strategies/implement/strategy.ts.sandcastle/README.md.sandcastle/tests/{merge-findings,refinement-loop,strategies,parse-findings,errors}.test.tsplus supporting kernel coverageBackward compatibility
When no ensemble fields are set:
resolveCriticSlotsreturns slots fromAGENT_CRITIC_POOL_DEFAULT.1 / 1; the merge kernel is bypassed on the N=1 path.Quality gates
All quality gates at HEAD are green:
pnpm typecheckpnpm lintpnpm test:sandcastlepnpm testpnpm buildOut of scope
perCriticOutputstask-source.tsReferences
Load-bearing prior art:
Background reading:
AI-generated code disclosure
Per SAP CONTRIBUTING_USING_GENAI guideline, this PR was produced with substantial AI assistance.