Skip to content

feat(bench): self-improving SWE harness — measured supervisor evolution + automated improvement loop#576

Open
drewstone wants to merge 61 commits into
mainfrom
feat/self-improve-e2e
Open

feat(bench): self-improving SWE harness — measured supervisor evolution + automated improvement loop#576
drewstone wants to merge 61 commits into
mainfrom
feat/self-improve-e2e

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Results first (what closed PR #544 lacked)

Every claim below was produced by the harness in this diff, graded by the official SWE-bench evaluator, and re-verified by hand.

  • Paired head-to-head, official judge, self-verified: glm-5.2 solo 7/10 vs the hierarchical supervisor 4/10 on the same pre-registered instances, with the supervisor at 2.55× solo cost. Publishing the honest negative is the point: the harness measures architectures instead of assuming them, and this number is what motivated the evolution loop below.
  • 3-round supervisor evolution (human-in-the-loop): round 3 recovered the matplotlib instance below solo cost on the improvement set; the pre-registered holdout block scored 4/6 vs solo 5/6, closing the paired gap from −30 to −17 points, with zero empty deliveries.
  • Round 4 — the automated loop (improve() in the optimizer seat): the machine-authored candidate resolved 2/3 vs 1/3 for the pinned baseline on the round's improvement set. Each resolved claim was re-judged personally through the official evaluator and the winning patches contain 0 test-file hunks (no test-gaming). Holdout certification is still running and will be posted to the experiment log before any promotion claim.
  • Full paired methodology + raw tables: docs/results/glm52-solo-vs-supervisor-swe-paired.md in tangle-network/supervisor-lab (branch feat/harness-aware-capabilities).

What's in the diff

61 commits, 113 files, +23.5k/−5.4k against main (merge of origin/main already resolved in-branch; git merge-tree proves a clean merge).

  • bench/src/swe-arena/ — the reviewable core (43 files, ~7.6k lines incl. tests): the typed SWE experiment harness.
    • M1 replay: typed round records, serialized official judging (flock-raced child), replay + parity tests proving the typed path reproduces the shell-era results.
    • M2 execution: real docker execution path, diagnosis ensemble, bootstrap metadata, capacity/calibration helpers.
    • Round-4 outer loop (outer-loop.mts): improve() drives the loops supervisor itself — pinned gate baseline, post-gate dispatch clock, author-shot guards.
  • src/improvement/ additions: rollout-policy improve surface, senior scientific-method optimizer prompt (optimizer-prompt.ts), driver-loop generator, typed AnalystFinding distiller envelopes, durable-run raw-trace default, campaign→OTLP trace export, findings helpers.
  • src/runtime/stdio-mcp-client.ts: the shared same-host stdio MCP connection (verifier and live serving share one code path), including the async stdin-EPIPE fast-fail from fix(improvement): handle MCP verifier stdin pipe errors #508 ported into the shared client.
  • Earlier structural rigs (bench/src/hev-improve.mts, mbpp-structural.mts, swe-structural.mts, …): the experiment-rig history that produced the structural-lever results feeding swe-arena's design.

Merge-resolution notes (the branch predates several main-side relands of its own work):

  • src/lifecycle was deleted on main (feat(improvement): unify measured candidate activation #564); this branch accepts that deletion and drops its lifecycle extensions plus the two superseded rigs that consumed them (self-improve-swe.mts, find-lift-swe.mts) — swe-arena supersedes both.
  • bench keeps main's published-package layout. There is intentionally no bench/pnpm-lock.yaml (main's convention): bench installs via the root pnpm workspace — pnpm install at the repo root is all a consumer runs.
  • bench/scripts/run-package-tests.mjs now partitions node:test files from vitest files (the swe-arena suite is vitest) instead of crashing vitest suites under node --test.

Verification (run on this exact merge)

  • Root: tsc --noEmit 0 errors, examples tsconfig 0 errors, biome clean, vitest 1633 passed / 2 skipped, 157/157 files, verify:package + docs:check freshness green (API docs regenerated).
  • Bench: typecheck:public 0 errors; full bench tsc has 0 errors in src/swe-arena (remaining errors live only in non-CI experiment-rig .mts files that predate main's API changes); run-package-tests 31/31 files (25 node:test + 6 vitest) + Pier bridge; packed-consumer verify green against local runtime.
  • swe-arena suite alone: 156 passed / 3 skipped.

Review guide

Start and end with bench/src/swe-arena/ — that is the deliverable. types.tsreplay.mtsexecutionouter-loop.mts, with the test files as the spec. The rest of the bench diff is experiment-rig history retained for provenance; it is not on any CI gate and does not ship in the public package surface (typecheck:public covers what ships).

Merge strategy

Recommend squash-merge — collapses historical commit trailers per repo policy.

🤖 Generated with Claude Code

drewstone and others added 30 commits July 8, 2026 02:53
Root-fix a scoring bug proven live: on django-12419 @ glm-4.6 the agent made the
EXACT gold fix but scored 0 because the harness extracted the patch by parsing the
model reply for a fenced diff block, and the model wrote prose instead. Two changes,
standard SWE-bench practice (SWE-agent/OpenHands read the diff from repo state and
pre-stage the checkout):

- boxSetup(task): harness clones the instance repo at base_commit into a fixed
  /work BEFORE the agent shot (same session) so the agent only edits. A stochastic
  model cannot be trusted to clone to an exact path (observed cannot-change-to-/work
  failures). Adapter-agnostic seam on BenchmarkAdapter.
- boxExtract(): git diff of the agent edits in /work (test files excluded; the judge
  applies the gold test_patch itself), preferred over the event-stream parse which
  stays the fallback. Prompt updated: repo pre-staged, agent only edits.

Typecheck clean; calibration still green. Live glm-5.2 verification blocked on
transient sandbox box-provisioning failures (box unavailable before start on a
green health) - re-run when the platform host-agent recovers.
…proposer

The code-surface proposer fed the coding agent the ~400-char DISTILLED
findings (generationFailureDistiller), not the raw traces. The meta-harness
edge (yoonholee.com/meta-harness) is raw-trace filesystem context: the coding
agent greps/cats the FULL run traces of failed candidates to diagnose, rather
than reading a pre-summary.

Add rawTraceDistiller() — an additive analyzeGeneration producer that, instead
of summarizing, points the proposer at the prior generation's real run traces
already durable on disk under runDir (per-cell spans.jsonl event logs +
cached-result.json scores + artifacts) with a grep/cat-to-diagnose instruction.
It emits AnalystFinding[] with ABSOLUTE paths (the harness runs from a worktree
cwd) so it drops into the same analyzeGeneration slot and renders through the
same agenticGenerator prompt path. The existing distiller stays the default.

improve() gains a one-line enable: opts.rawTraceContext = true wires
rawTraceDistiller() when the caller has not supplied their own analyzeGeneration
(explicit analyzeGeneration still wins; null still disables).

Self-test builds a real tmp runDir with fake candidate + trace files and asserts
the findings reference the actual absolute trace-file paths + the grep/cat
instruction, plus worst-candidate-first ordering and the clean-generation
fallback. tsc clean, biome clean, 3/3 new tests + 7/7 existing improve tests pass.
# Conflicts:
#	bench/src/benchmarks/swe-bench.ts
#	bench/src/run-benchmarks.ts
#	docs/api/primitive-catalog.md
#	src/improvement/raw-trace-distiller.test.ts
#	src/improvement/raw-trace-distiller.ts
…point

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f-k + self-repair on docstring examples)

Selection + repair grounded only on visible docstring >>> examples (doctest,
nonce-sentinel), hidden check() grading physically after all harness decisions.
Paired bootstrap + exact sign test. Calibrated: 164/164 hidden self-check,
75/164 coverage, 2/75 false-fail (dataset bugs). First result: Llama-3-8B
44.3%->57.3% pass@1 (+13.0pp, CI [+7.8,+18.4], p=1.5e-5, n=164).
humaneval.ts: HUMANEVAL_GZ local-cache support (GitHub raw 429s under reps).
…ructural-lever rig

Model writes single-line asserts from the prompt alone, BEFORE any candidate
exists; asserts run individually in the jail and add to the doctest signal
(split counts kept for audit). Extends honest-oracle coverage from 46% of
HumanEval toward 100%. Opt-in via TESTGEN=<count>; default off keeps prior
runs reproducible.
…EN), hidden=remaining asserts

Same architecture as hev-structural (nonce judges, phase separation, incremental
persistence, bootstrap+sign-test). Calibration: 427/427 hidden self-check, 0/427
visible false-fail, 0 dropped. Official-first scoring: MBPP one-sentence specs
make model-guessed asserts ~70% wrong on passing code — shown assert ranks first,
guesses break ties (pilot: selection -6.7pp -> +16.7pp).
… only net-new seam; extend strategy family, verifier-environment, field routing
…tool-loop conversation

runBrainLoop returned on a no-tool-call turn WITHOUT pushing the final assistant
message, so ToolLoopResult.messages violated its own contract (seed + every
assistant/tool turn): a shot that answers without tools came back with a transcript
missing its own answer. Depth continuation then criticized a solution absent from the
model's history, and structuralRollout's fenced-code candidate extraction
(defaultExtractCandidate's designed fallback) read an empty conversation on
non-tool-calling models (Llama-3-8B-Instruct-Lite never tool-calls under
tool_choice:auto — measured on Together).

Proven by the runtime smoke (bench/src/smoke-structural-rollout.mts): candidates now
flow through the strategy on a real non-tool-calling model.
…ory-less consults

consultAnalyst rendered EVERY raw consult in analyst framing (instruction as system,
'TASK: ...TRAJECTORY:' as the user turn). With no trajectory — the pre-task case,
i.e. structuralRollout's authored-check generation — that user turn is just the coding
task, which a weak model answers directly, ignoring the system instruction: measured
on Llama-3-8B-Instruct-Lite, authored-assert yield 6/18 reps (0 asserts on the rest)
vs 18/18 with the instruction and task fused into one user message (the proven
hev-structural rig shape). Trajectory-bearing consults keep the analyst framing
unchanged.

Runtime smoke after the fix: authored checks on 20/20 HumanEval tasks (was 14/20).
…Eval, hidden grading script-side

The ship gate for the ported strategy: runAgentic + structuralRollout(default policy:
k=5, repairs<=2, testgen=6) over createVerifierEnvironment with an INERT check, so no
hidden-test signal reaches selection/repair; visible checks are the strategy's own
model-authored asserts run by the shipped sandboxCheckRunner over a docker
--network=none exec channel (the script's thin adapter). The task's own check() suite
grades every locked candidate script-side (runChecker) after each rollout finishes.
Receipts are hard-verified against the recorded outcomes per candidate.

Llama-3-8B-Instruct-Lite (Together), first 20 tasks: blind mean-of-k 63.0% ->
selected 80.0% -> final 85.0% (+22pp), authored checks 20/20, 4 tasks rescued
(selection or guarded repair passes hidden where sample 0 fails), 0 crashes.
…g (arms A-D, hard/control sets)

Implements supervisor-lab PREREG-supervisor-showdown.md: fixed 62-hard/20-control
HumanEval sets, equal-compute worker loop (k=5, testgen=6, <=2 repairs), visible-only
evidence rendering, no-code plan contract with leak stripping + planLeaked audit,
Phase A/B separation with the rig-local nonce hidden judge, per-model token+cost rows.
…glm plan calls exceed 240s under sustained load
…ator

Per PREREG-swe-frontier Stage 0: glm-5.2 authors a repro script from the
issue text (+ up to 3 requested file reads); validity = nonzero exit on the
unpatched tree (1 feedback retry), soundness = exit 0 after the gold patch
applied host-side to a COPY of the tree, run in the same :ro-mount jail as
the env's run tool. Gold is script-side only, enforced by a leak assert on
every outbound message. PYTHONPATH=/testbed pins imports to the mounted
tree (script-path sys.path would silently test the image's baked-in
install). swe-bench-env: image resolution extracted to exported
resolveImageForMetadata (no behavior change).
…e are not a script

Observed live on astropy__astropy-13033: the model fenced its READ: lines,
extractScript shipped them to the jail, and the NameError crash registered
as a false 'bug detected'. A fenced block consisting solely of READ: lines
now routes to the read round.
…t the image's built /testbed

Stage 0 measured the mount substrate killing 6/23 instances (astropy x2,
matplotlib, sklearn x2, pytest) with import errors a fresh un-built clone
cannot avoid (compiled extensions / generated version files live only in
the image's own /testbed). The same 6 scripts were all valid+sound when
executed in-image with the gold applied in-container (writable layer,
discarded by --rm; __GOLD_APPLIED__ sentinel separates apply-failure from
still-failing). REPRO_EXEC=image makes that substrate a first-class mode.
…ttempts)

The 56s total backoff lost 5/23 instances to a sustained zai 429 burst at
the tail of a conc-3 run (0 model calls, whole instances recorded as
infra-error). Rate-limit retries now climb 30s/60s/120s/240s while
transient errors keep the short ladder.
…script extraction

Observed live on pylint-dev__pylint-7080: a reply opening with two
consecutive ```python lines made the non-greedy fence match capture the
empty span between them, discarding a complete script as authoring-failed.
… the grading substrate

- canary: with the gold patch applied to the tree under test, import <pkg>
  must succeed AND resolve INTO that tree (exit 3 = site-packages shadow);
  a canary failure marks the instance env-unresolvable in that mode before
  any model call is spent
- CANARY_ONLY=1: substrate-trust sweep with zero model calls (image mode
  also skips the host clone)
- mount substrate: gold applied host-side up front, so the canary observes
  the tree exactly as the soundness run mounts it
- 429 ladder raised to 60s/120s/240s over 7 attempts (the overnight
  code-1305 storm zeroed 6/23 instances on the 30s ladder)

Measured on the 23-instance Stage 0 backbone: image canary 23/23 pass,
mount canary 17/23 (the 6 compiled-package instances fail, matching the
prereg amendment's diagnosis).
…ction + guarded repair vs solo

swe-structural.mts runs both PREREG-swe-frontier Stage-1 arms on the cached backbone:
ARM=system = canary-asserted image substrate, Stage-0 repro reuse (re-verified per instance:
validity without gold, soundness with gold in-container), k independent emit-patch attempts,
in-image candidate scoring (git apply to /testbed + repro), argmax (repro-pass first,
crash-lowest, first-index ties), <=2 strictly-improving repair rounds, then the official
swebench judge serialized in Phase B after every arm decision locks. ARM=solo = one
emit-patch attempt, the July-protocol reference.

- swe-jail.ts: the calibrator's canary-verified jail/zai-ladder/canary primitives extracted
  verbatim (swe-repro-calibrate now imports them — one jail, no fork) + assertNoHiddenLeak,
  the transport-chokepoint judge-separation guard (system/user messages only: assistant/tool
  text is the model's own or reads of the visible tree).
- swe-bench-env.ts: opt-in cloneCache — one GitHub clone per instance, local copies per open,
  so k-attempt sampling cannot die on a clone flake mid-run. Default off, baseline unchanged.
- Rows are incremental (OUT.phaseA then OUT post-judge) and both phases resume by instance id.
…ralRollout dials on real HumanEval with the library's held-out gate

The first live campaign on the merged machinery: the 'rollout-policy'
improve() surface (deterministic neighbor proposer) drives a REAL evaluator
— runAgentic + structuralRollout over an inert verifier surface, visible
checks via sandboxCheckRunner on a semaphored docker --network=none channel,
script-side hidden grading by the nonce-sentinel judge — with fixed disjoint
slices (DEV = HumanEval [0,60), HELD-OUT = [60,120)) passed as explicit
budget.holdoutScenarios so the library enforces disjointness and its own
defaultProductionGate (paired bootstrap, ship iff CI.low > 0.05) makes the
ship/hold call. analyzeGeneration: null keeps the improver's context to DEV
composites only. Worker default is Qwen2.5-7B-Instruct-Turbo: the original
Meta-Llama-3-8B-Instruct-Lite and every 8B Llama variant were retired from
Together serverless (model_not_available, verified 2026-07).
…ow-up on a headroom config

Identical improve() rollout-policy loop as live-improve-campaign.mts (HumanEval,
DEV saturated at 90%, gate HELD), moved to sanitized MBPP where Qwen2.5-7B
measures 76.7% at k5/r2/t6 vs an 85.9% pass@5 bound. Shown assert rides
task.meta.visibleChecks into officialChecksFromMeta so the official check
outranks authored guesses; test_imports prepended for both judges; DEV =
usable index [0,150), HELD-OUT = [150,300); MAX_CALLS hard cap + fail-loud
model preflight + recompute-from-raw holdout cross-check added.

mbpp-structural.mts gains an entrypoint guard (corpus-replay precedent) and
exports basePrompt so the campaign imports the loader without launching the
benchmark; direct execution is unchanged.
…mpty-holdout crash

When the worker stops answering mid-run (Together HTTP 402 credit exceeded /
429 rate limit / outage), the runtime swallows exhausted-retry shots as null,
so cells silently degrade (calls/cell 15 -> 3) and the run dies hundreds of
cells later at the holdout with a cryptic 'no scorable cells' gate error.

Now: when a cell produces zero candidates (repairStop=no-candidates, every shot
null), probe the worker directly and abort loud with the real HTTP status
(e.g. 402 credit exceeded) so the operator sees the actual cause immediately.
A healthy probe means the null was a one-off and only that cell fails.

Motivated by a live run: the loop completed all 1350 DEV/gen cells and picked
{k9,r2,t6} (+5.3pp DEV over the 82.0% baseline) but exhausted the Together
credit balance at the holdout phase before the gate could render a verdict.
…river over SWE-bench Verified

Contract: supervisor-lab/docs/design/stream-loop.md. Base refactor of the
Stage-1 structural pilot (swe-structural.mts @ 393ee50) into a running
stream: F (frozen-v0: canary, manifest-repro reuse else fresh authoring,
k=4 repro-argmax, <=2 guarded repairs) and L (byte-identical + memory
recall via promptAppendix over an initially-empty agent-knowledge store,
templated outcome-anchored notes, failure-class tally), interleaved per
instance, official judge serialized per instance after arm decisions lock.

Hard driver requirements (each traces to a measured Stage-1 failure):
- per-instance DEADLINE_MS race (29h hang) -> error rows w/ partial
  receipts, stream continues; deadline reaches into the zai retry ladder
- per-candidate TURN_CAP at the transport chokepoint (160-call blowup) ->
  synthetic no-tool-call completion, candidate scored as-is, receipted
- zai conc <=2 total, 429 ladder 60/120/240s, 480s client timeout
- explicit image pull -> run -> delete rotation with a hard presence
  assert; SWEBENCH_CACHE_LEVEL env override (default env) because the
  judge's cache_level=env silently pruned the 23-image fingerprint cache

Ledger: one JSONL row per (instance x arm) with all structural receipts +
recall/write receipts + cumulative resolved/$ per arm; events.jsonl incl.
the stubbed every-25 batch-look; append-only persisted plan (stable
streamIndex across continuations); resume by instance id.

Shared protocol lift: repro-authoring prompt/parsers move from the Stage-0
calibrator into swe-jail (byte-identical), zaiChatRaw gains an optional
deadlineAt, IMPORT_NAME covers seaborn/xarray.
…125 (5 attempts, backoff)

Two defects the stream's all-error run exposed: (1) the docker-failure path read
err.stdout but docker writes exit-125/mount/OCI errors to STDERR, so every infra
failure surfaced as a bare 'docker: ' with no cause; (2) a transient daemon
window (125) fatal-errored the instance — and since erroring instances burn
through instantly with no model calls, one bad window nuked all 23 in ~3 min.
Now: stderr captured, and 125/daemon-unreachable/overlay/no-space retried with
backoff before being surfaced as infra error.
drewstone added 26 commits July 12, 2026 20:41
…uced nothing, the case a plan helps most; hold only already-passing + repro-timeout
…ashboard from a stream dir's ledger/events (reproducible, re-runnable for live state)
…with labeled nodes (role/model/harness/transport), and correct the mental model (best-of-k + supervised-repair, NOT supervisor-spawns-workers)
…SWE Docker judge

Fills the EvalRunner hole (marginal-lift.ts): drive each pinned Verified
instance with the multi-turn atom (runAgentic + refine) under the profile's
prompt, capture the git diff, grade it with the swebench Docker judge held
outside the agent, and return { composite: mean(resolved), costUsd }.

The environment/tasks/judge are injected (bench owns them; importing bench
from src would invert the package dependency). The driven system prompt folds
profile.prompt.instructions in after systemPrompt — the surface applyArtifact
mutates for prompt artifacts — so a prompt candidate measurably changes the
worker instead of scoring a fake zero delta. All-judge-failures throws rather
than reporting a fabricated composite.

bench/src/self-improve-swe.mts wires it end-to-end: promptGenerator (one
deterministic candidate via the injected refine seam) -> runLifecycle ->
thresholdPromotionGate(0) -> composeProfile, with an SMOKE=1 import-only path.
…-loop tool builder

Replace the thin builder/author prompts with one shared senior doctrine
(src/improvement/optimizer-prompt.ts): diagnose the dominant failure mode,
state a hypothesis with a predicted lift, decompose into checkable sub-goals,
design to isolate the mechanism, generalize past the shown findings, preserve
what works, verify for real, then reflect. Seeded from GEPA's
REFLECTION_SYSTEM and the /evolve, /pursue, self-improving-loop skill docs;
embedded by toolBuildPrompt / mcpBuildPrompt / defaultBuildPrompt and by
authorStrategy's authoring instruction.

Add driverLoopGenerator: the driver->worker CandidateGenerator on the shipped
atom (runBrainLoop + ToolLoopChat, the same seam driverAgent runs on). A
driver LLM authors each worker instruction, observes the session's diff /
files / verifier output, rates it, and decides refine / re-scope / decompose
- replacing agenticGenerator's canned EMPTY_TREE_NOTE/failureNote respawn.
Workers stay runLocalHarness in the candidate worktree; agenticGenerator is
kept intact as the offline path. worktreeBuildCandidate wires the driver loop
as the tool/mcp build default whenever a driver brain is configured.

Completion oracle stays code-owned: after the driver stops, ground truth
(dirty tree + raw-trace evidence + verifier exit) decides applied, never the
driver's prose. Scripted-brain unit tests prove instruction threading, refine
on red verifier, fail-closed gating, and the session cap.
…P into the scorer

Phase 3 of the self-improve loop: a worktree-BUILT MCP server (stdio, cwd on
the host) was unreachable by every shipped backend — none spawned profile.mcp
stdio servers same-host, so a built tool could never be scored live.

- connectStdioMcp (runtime/stdio-mcp-client): the ONE persistent
  newline-JSON-RPC spawn+handshake, extracted from mcpServeVerifier's probe;
  the verifier is rebased on it so 'verified it serves' and 'served while
  scored' can never drift. Spawn faults stay McpSpawnFault (setup bug, thrown);
  crash/timeout stays a failed candidate with the stderr tail.
- materializeLocalMcp: spawn every enabled stdio server in profile.mcp,
  namespace tools <server>__<tool> (provider-safe schemas via the shared
  sanitizeMcpToolSchema), fail-closed when a declared server cannot boot.
- localSandboxClient + resolveSandboxClient backend:'local': the same-host
  pseudo-box — router-brain tool loop (runBrainLoop) with the profile's MCP
  tools live; profile arrives per-create on backend.profile; delete() kills
  the children. Event protocol matches inlineSandboxClient.
- sweEvalRunner opts.materializeMcp: overlay the live MCP tools onto the
  driven surface (tools()/call()) for the eval's duration, close in finally.
  Default stays prompt-only.
- bench self-improve-swe.mts SURFACE=mcp: buildableGenerator over
  worktreeBuildCandidate (driver-loop brain via routerBrain), ranked/gated by
  the SAME eval runner with materializeMcp on.

Verified: tsc green (root+examples); vitest 1318 passed 0 failed; chain smoke
mcpServeVerifier -> applyArtifact -> materializeLocalMcp round-trips a live
cwd-bound tools/call.
…sers consume real runs

Phase 4 of the self-improve loop: kill the trace-ingestion rot between what
the loop RECORDS and what its analysts READ.

- campaign-otlp: the missing converter from the campaign trace writer's
  per-cell spans.jsonl ({name, cellId, startMs, durationMs, ...attrs}) to the
  OTLP-flat JSONL OtlpFileTraceStore/the trace-analyst registry parse, plus
  campaignTraceResolver(runDir) — the resolveTraces implementation for
  traceAnalystProposer/haloProposer: proposing generation g reads gen-(g-1)
  (baseline for g=0) under the same runDir the loop records into. One trace
  per cell keyed on the cell's on-disk path (the same cellId recurs across
  baseline and every candidate); deterministic FNV folds to 32/16-hex ids.
- findings: isAnalystFinding + toAnalystFindings replace the blind
  'as AnalystFinding[]' cast in improvement-driver — real findings pass by
  reference, untyped seeds are lifted into makeFinding envelopes (claim = the
  curator extraction order, original under metadata.raw), garbage dropped
  without throwing. Build prompts can no longer render undefined claims.
- improve(): rawTraceDistiller is now the DEFAULT analyzeGeneration for
  durable runs (real runDir — where the traces live); the in-memory digest
  distiller stays for mem:// runs and now emits TYPED AnalystFindings too,
  so exactly one findings shape crosses the wire. rawTraceContext becomes the
  explicit override in either direction.

Proof: vitest drives the real published runCampaign recording -> resolver ->
OtlpFileTraceStore (2 traces indexed, tool names visible) ->
traceAnalystProposer returns a candidate, offline via its analyze/fetch seams.
Converter lives in this repo because agent-runtime-swe consumes the published
agent-eval (0.108.0); lifting it into agent-eval next release is the follow-up.
… via stdio MCP

Adds the 'memory' ArtifactKind + AgentMemorySpec payload. applyArtifact
mounts it twice: the typed spec on profile.metadata.memory (local
extension — the published AgentProfile has no memory field yet) and a
live stdio server entry on profile.mcp, so the same-host client
(materializeLocalMcp) boots the memory during a scored run and
sweEvalRunner({ materializeMcp: true }) measures memory-as-treatment
lift with zero scorer changes.

The memory MCP server (memory_search / memory_get, deterministic
lexical retrieval) serves on the extracted createStdioToolServer core
(committed here; the memory server is its first consumer), with a bin
entry (agent-runtime-memory-mcp) resolved per install: dist/mcp/
memory-bin.js under node when built, the TS source through tsx in
dev/test. Fail-closed: an empty memory never serves; a broken store
fails the materialization instead of faking the ablation.

Lesson ingestion: memoryArtifactFromLessons /
memoryArtifactFromCuratedSurface adapt agent-eval's
memoryCurationProposer block (markers duplicated by convention —
flagged) into memory artifacts; memoryGenerator is the native
CandidateGenerator (observed findings only — judge-derived rows are
firewalled; carried memory accumulates; no-change generations emit
nothing). AgentMemorySpec.logPath writes a JSONL row per memory_search
— the retrieval-log seam for agent-knowledge's RetrievalHoldout, which
stays cross-package (not a dependency of this repo).

Also fixes a stale improvement-driver test: its partial report-finding
fixture now conforms to the P4 toAnalystFindings pass-through contract
instead of asserting a pre-lift id.
…or the research optimizer

- 'connection' ArtifactKind: an ExternalMcpGrant (http url / stdio launch,
  literal headers/env, secrets by provider KEY NAME, optional hub grant)
  promotes onto profile.mcp[key] via connectionMcpServer and onto
  profile.connections (artifact wins per connectionId)
- buildableGenerator: BuiltCandidate.remote branches the mcp emit off the
  hardcoded stdio transport — the adopt-not-build path emits a
  { transport:'http', url, headers, env } server with adopt provenance
- KeyProvider (runtime/key-provider.ts): declarative secretEnv
  (mcp[key].metadata.secretEnv, names only) resolved at materialize time;
  envKeyProvider reads the dotenvx-loaded env; materializeLocalMcp injects
  values into the spawned server child env only — fail-closed on a missing
  provider/key, values never on the profile or in logs; sweEvalRunner
  forwards keys so an adopted server scores with its credential live
- research seam: driverLoopGenerator gains an optional research{query} tool
  + researchDriverNote (adopt-before-build doctrine), offered only when
  wired; mcpBuildPrompt instructs adopt-over-build; no live web backend
  ships yet (the seam is worktreeBuildCandidate driver.research)
The e2e driver imports sweEvalRunner, which lives in ../src and resolves only
through bench/tsconfig path aliases (tsx applies them at cwd=bench). Running
tsx bench/src/... from the repo root resolved the published agent-runtime in
bench/node_modules and crashed on the missing export. Add a bench package
script (self-improve) so the invocation always runs at cwd=bench, and document
the requirement in the driver header.
…/repeat on the SWE judge

New bench/src/find-lift-swe.mts closes the intelligence loop across rounds: each
round scores the current profile on the held-out set (sweEvalRunner), autopsies
the failing rows into ranked mechanism findings (autopsySweFailures), measures a
finding-authored population (reflectiveSweRefine + authorDiverseSweSeeds through
promptGenerator, gated by thresholdPromotionGate(0) on the Docker judge held
outside the agent), then composes the round's promoted winners onto the profile
for the next round (composeProfile). Converges early when a round promotes
nothing and the findings are unchanged; else runs ROUNDS. Writes per-round JSON
(composite, findings, per-candidate lift/promoted, composed instructions) and
prints a final trajectory table. SMOKE=1 is import-only.

Exports the shipped reflective-swe primitives (autopsySweFailures,
reflectiveSweRefine, authorDiverseSweSeeds, + SweFailureRow/AutopsyOptions/
ReflectiveSweOptions/FailureMechanism/FAILURE_MECHANISMS) from the lifecycle
barrel so the bench resolves them through the same path alias as sweEvalRunner.
…back

The autopsy's LLM classifier is enrichment, not a hard dependency: a transient
router 502/503 mid-run was throwing away a whole scored round. Catch the
classification failure and fall back to an empty map — every failing row then
resolves to its deterministic mechanism (empty diffs detected without the model)
via normalizeMechanism/defaultEvidence, so the round still ranks failures and
proposes candidates.
…to no-candidate

The candidate author needs the model (a targeted prompt is not deterministic),
so unlike the autopsy it cannot fall back. Wrap its structured call in a
wall-clock retry budget (REFLECT_AUTHOR_RETRY_BUDGET_MS, default 240s) with
jittered backoff; if the router is genuinely down past the budget, return [] —
a legitimate no-proposal round that scores baseline and promotes nothing,
never a crashed run that discards the round's rollouts.
…02s on schema)

Diagnosed: the Tangle router returns 502 (CF gateway) on schema-enforced
structured output for the reflection calls — haiku rejects json_schema outright,
glm times out on author-sized json_schema requests. Both models return clean
json_object. Switch autopsy + author to jsonMode with the exact shape pinned in
the prompt and validated on parse (the defensive filters already tolerate a
missing/extra field), so the autopsy->evolve calls actually get through.
A transient router 5xx makes the worker return 0 completions -> 0 tokens, which
sweEvalRunner scores as 0/resolved. Without a guard, a blip silently produces a
fake all-zero baseline and the whole round (autopsy + paid candidate arms) is
measured against it - wasting money on corrupted science. Abort the round when
>50% of baseline rollouts return 0 tokens; re-run when the provider recovers.
…t arm

runLifecycle always re-scored its own baseline arm. A round driver that already
scored the identical baseline profile (to read its failures for an autopsy) then
paid for a second, redundant scoring of the same profile every round. Add an
optional baselineResult to RunLifecycleOptions; when supplied, use it as the
shared 'without' arm instead of re-running. The find-lift driver now passes its
step-(a) baseline through — a full arm of compute saved per round.
…ot in-sample

The driver autopsied AND scored candidates on the SAME task set, so a proposed
instruction was tuned on the exact tasks it was measured on (train-on-test) —
the reported lift was in-sample and optimistic. Split into TRAIN_IDS (the autopsy
learns from + the search optimizes on) and a DISJOINT frozen HOLDOUT_IDS the
autopsy never reads; certify the composed profile on the holdout each round. The
reported lift is now (composed on holdout) - (bare baseline on holdout) = a real
generalization number. Guards the holdout baseline against dead-worker blips too.
runAgentic throws 'all-children-down' when a transient router 5xx downs every
shot for a task. evaluateTask did not catch it, so ONE task's blip crashed the
entire eval (and the whole find-lift round) mid-flight. Catch it: score that task
0 with 0 tokens + carry the error, so the aggregate dead-worker guard decides on
the batch (abort a mostly-dead round, tolerate a single blip) instead of an
uncaught crash. Makes the eval robust to intermittent provider weather.
When the search promotes 0 candidates, the composed profile is byte-identical to
the one that produced the holdout baseline. Re-scoring it and subtracting reports
pure worker run-to-run VARIANCE as 'lift' — a false positive (a run just printed
'+25pp (real, frozen)' with 0 promoted / 0 instructions changed: 12.5% vs 37.5%
was the same bare profile scored twice). Only re-score + report a holdout lift
when the profile actually changed; otherwise the lift is 0 by construction.
…ev/swe rigs

- hev-improve: on failure the judge notes now carry the Docker checker's
  traceback/assertion tail (last 800 chars) plus the model's emitted code
  (first 700 chars) instead of the single word 'failed'; rawTraceContext
  stays off deliberately (the prompt-tier gepaProposer cannot run grep over
  trace paths — evidence arrives via notes/emitted instead)
- hev-improve: GENERATIONS/POPULATION defaults 1/2 -> 6/4 so the default
  run exercises the Pareto/combine path (generations=1 never fired it)
- swe-improve: judge notes widened from the 200-char head of the swebench
  report JSON to 1500 chars (the report is flat — no separate failure
  section to extract)
…at equal budget

Fast, self-auditing research instrument: HumanEval, local subprocess grading
(seconds/task, no Docker clone), equal-budget IMPROVER (blind self-refine) vs
CAPABILITY (run_python tool) arms, forced final answer, per-unit resilience,
paired McNemar, empty-final-code artifact detection. Finding (EXP-038): NULL at
power — a code-execution tool does not beat blind self-refinement at equal budget
on HumanEval (gpt-4o-mini n=492: 86.4% vs 88.0%, p=0.30); the headroom-conditional
law stays untested at the large headroom where the SWE prior lives.
…rovement loop (round 4) (#575)

* feat(bench): swe-arena typed replay of the solo-vs-supervisor head-to-head

Milestone 1 of the swe-arena unification: load the committed head-to-head
artifacts (12 paired SWE-bench Verified runs, glm-5.2 solo vs supervisor)
and reproduce the reference analyze.py analysis exactly, pinned as tests.

- types.ts: LedgerRow/RejudgeRow/RematchRow/ArmSpec/HoldoutRegistry mirroring
  the fixture schemas; SweInstance for the task-meta fields we consume.
- reconcile.ts: verdict reconciliation as pure functions — personal re-judges
  (*-final/*-final2/my-reverify) override automated verdicts; gold rows that
  the judge cannot resolve mark the instance ungradeable and shrink the
  denominator (2931 + 2317 excluded -> solo 7/10 vs sup 4/10).
- analyze.ts: discordant pairs, exact sign test via agent-eval mcnemar
  (p=0.25 on 3 solo-only wins), cost/wall rollups with both token views:
  brain-only 560,554 (0.83x, what analyze.py printed) and true spend
  1,722,390 incl. 1,161,836 worker tokens (2.55x solo).
- replay.mts: CLI whose reference section is byte-identical to analyze.py
  output (diff-verified), plus reconciled verdict, rounds progression
  (matplotlib 0-line unresolved at SUP -> resolved at SUP4), holdout registry.
- replay.test.mts: 56 vitest tests pinning sha256 of every fixture and every
  oracle number above; bench-scoped vitest.config.ts (root config excludes
  bench/**, other bench tests are tsx scripts).

Fixtures copied verbatim from the experiment scratchpad (secret-scanned);
sup-journal-true.json is derived from the supervisor journals with the same
extraction as analyze.py so the token rollup survives scratchpad deletion.

* feat(bench): swe-arena M2 — typed execution path proven by 2-patch judge parity

Replaces the bash head-to-head harness (setup-ws/calibrate/judge/solo/sup4/
probe-capacity/orchestrate) with typed modules on top of the M1 replay:

- materialize.ts: workspace from instance images (docker create/cp → reset
  --hard base → work branch); image path stays default — clone drops the
  environment_setup_commit build state (proven live in evolution round 2)
- calibrate.ts: dual gate — repro (base FAILS, gold PASSES; git apply →
  patch --fuzz=3 fallback) + official-judge gold gate via adapter.judge;
  experiment-valid only if both hold
- serialized-judge.ts + judge-child.mts: one judge at a time (in-process +
  cross-process pid lock — the pre-flock race caused a proven false-negative
  on psf__requests-1766), enforced 1800s ceiling floor (700s caused 3
  spurious timeouts on requests-2317), stale-container pre-clean inside the
  mutex, one retry on empty/parse-fail, resolved:null when inconclusive
- arms.ts: solo (opencode + oc-jsonl usage parse) and supervisor (wraps the
  real loops driver via the now-tracked run-supervisor.mjs shim; env knobs
  incl LOOPS_BRAIN_RETRIES/DRIVER_DEADLINE_MS) arm execution; patch
  extraction with the vendored excludes; cost recovery = journal metered
  events + workers-ndjson cwd → opencode sqlite join (worker-tokens.json
  provenance, verified byte-exact on flask-5014: 46737); armProvenance
  records the live loops commit + env knobs
- capacity.ts: EndpointCapacityGate (k-of-n, steadyM, wait ceiling) with
  BOTH probe paths — z.ai coding (worker) and router.tangle.tools (brain);
  supervisor arms gate on the router path (3 infra-null rounds came from
  probing z.ai only)
- run-experiment.mts: sequential paired runner with ledger-skip resume,
  serialized judging, M1-schema LedgerRow append, 15s cooldown; --dry-run-parity
  replays extraction + judging from committed patches with zero model spend

Parity gate (ran for real, docker only): pallets__flask-5014 solo →
resolved=true (22s, attempts=1) and pydata__xarray-4687 sup →
resolved=false (71s, attempts=1), matching the pinned M1 ledger/rejudge
verdicts; changed-file sets preserved through re-extraction. Full
12-instance arm parity re-run is a TODO gated on operator approval
(~$1 + 4h).

tsc: 34 pre-existing errors before and after, 0 in src/swe-arena.
vitest: 93 passed (56 M1 + 37 new) + 3-test docker parity suite
(opt-in via SWE_ARENA_PARITY=1, verified green twice).

* feat(bench): round-4 outer loop — improve() in the optimizer seat over the loops supervisor

One runRound() = one improve(surface:'code') call on the loops repo:
DIAGNOSE (blind multi-model analyst ensemble over prior-round failure
artifacts, fused union + agreement-rank, minority findings kept as
competing hypotheses, composed with rawTraceDistiller at the
analyzeGeneration seam) -> PROPOSE (agenticGenerator constrained to the
declared change-space: extensions/pi/** + src/{worker-evidence,
best-effort,worker-clone}.ts, enforced in the shot verifier AND
fail-closed pre-spend in the dispatch) -> EVALUATE (each candidate
commit gets a detached loops eval worktree; supervisor arm + serialized
official judge over the 3-instance improvement set; frozen round-3 arm
params asserted immutable) -> ACCEPT/REJECT (protocol_v2 keep-if-better
with the +20% cost guard; gate always holds — the pre-registered
6-instance holdout runs only on operator approval; every candidate
persists as a staircase row in supervisor-lab .evolve/rounds).

Calibration hardening measured live on the round-2 django SUP2 smoke:
one transport retry (router 524 storm observed), temperature spread for
same-model analysts (temp-0 duplicates were byte-identical via edge
cache), 16k analyst token ceiling (glm-5.2 reasoning consumed a full 8k
ceiling leaving empty content), z.ai endpoint fallback flag.

Deps: pin agent-eval/agent-runtime to the local file: builds (0.121.0 /
0.94.13) — improve()'s code surface, promotionGate, dispatchTimeoutMs
and rawTraceDistiller do not exist in the old published pins. The
refresh drifts 4 pre-existing type errors in retired experiment rigs
(34 -> 38 total, all in files already red); swe-arena stays at 0.

Tests: +37 vitest units (fusion, analyst-output parsing, change-space
enforcement incl. traversal/rename edge cases, staircase row schema,
keep-if-better verdicts, frozen-arm assertion, prompt contract).

* feat(swe-arena): metadata bootstrap, post-gate dispatch clock, repsPerInstance

Rebuild after a host reboot wiped the experiment scratchpad (/tmp), and fix
the pre-crash abort:

- bootstrap-meta.mts: regenerate task-meta.json / problem statements / dual
  calibration straight from the SWE-bench Verified dataset via the bench
  adapter — the scratch task-meta.json hand-file dependency is gone for good.
  Honesty firewall preserved: --problems emits problem statements only; gold
  patches surface only on the mechanical calibration path.
- fixtures/verify/: the 3 improvement-set verify scripts, re-authored from
  problem statements only and dual-calibrated (repro base-fail/gold-pass AND
  official-judge gold gate; all 3 experiment-valid). Committed as fixtures —
  the durable home — and defaultRound4Config now points at them.
- Dispatch clock fix: the campaign clock raced the ENTIRE dispatch including
  capacity-gate waits, so a 58-min gate hold was billed to the 7200s cell
  clock and the astropy baseline cell aborted 'rejected-incomplete'. The cell
  work clock (runWithPostGateClock) now starts after the gates clear; the
  campaign clock is widened by the worst-case gate holds as a backstop.
- repsPerInstance (round 4 default: 2): replicate cells per instance; an
  instance counts resolved only when ALL replicates resolve (single-rep
  scoring flips instance outcomes run-to-run). Per-rep run dirs, staircase
  rows carry rep, parent resolved-count read from the parent's own record.
- analyzeGeneration skips seed artifact dirs that no longer exist (wiped
  scratch must not feed empty bundles to the diagnosis analysts).

Tests: 143 passed (130 baseline + 13 new units), tsc adds 0 errors to
swe-arena.

* fix(swe-arena): purge gitignored proposer dirt before finalize + salvage gen-0 candidates

The round-4 gen-1 run died at the substrate's finalize-time integrity
check: the cand-1 proposer ran a real pnpm install inside its CodeSurface
worktree (38k node_modules paths). Ignored paths pass the change-space
check (git status honors .gitignore) but verifyCodeSurfaceWithGit rejects
ANY extra path, ignored included. purgeIgnoredArtifacts (git clean -Xdff)
now runs as step 1 of loopsCandidateVerifier — the last our-code
touchpoint before finalize — preserving tracked edits and untracked
deliverables. Arm evals already ran in detached eval worktrees; that
seam was not the dirt source.

Tests: purge semantics + tree-hash invariance of a finalized candidate
worktree across a mocked evaluation. Salvage: both machine-proposed
candidate diffs recovered from unreachable commits (pinned as
refs/salvage/* in the loops repo) with provenance README.

* fix(swe-arena): pin the gate baseline, double author-shot timeout, add launch guards

Gen-1 close-out (r4-mroh3rkt) graded 'winner 0/3 vs baseline 0/3' while the
reps-confirmed measured baseline was 1/3 — a bookkeeping fault, not variance:
improve() resumes its campaign from runDir and replays cached baseline cells
WITHOUT dispatching them, so the in-process RoundRecorder labeled the first
dispatched CANDIDATE (b08d31c910) as the baseline and published its partial
cells as the baseline record (loop-provenance baselineSearchComposite=0.5
proves the campaign layer kept the true baseline verdicts throughout).

- pinnedBaseline config (iid -> reps-confirmed bool): the accept/reject gate
  and staircase parent counts compare against the PIN, never a per-run
  recomputation; contradicting run cells log a loud BASELINE-DRIFT warning
  with both values and the pin still wins. Malformed pins fail at t=0.
- RoundRecorder identifies the baseline by candidateCommit == baseCommit
  instead of dispatch order, so a resume can never mislabel a candidate.
- proposerTimeoutMs default 20 -> 40 min: the agenticGenerator per-shot clock
  ('author shot timed out') fired 3x under degraded capacity in gen-1.
- Launch guards on the spend path: refuse without TANGLE_API_KEY+ZAI_API_KEY
  (names dotenvx), and a pid-file single-instance lock per outDir with a
  staleness check so two outer-loops can never race.
- --write-config --out-name <dirname> so a new generation gets a fresh
  campaign home instead of resuming the completed one.

* fix(outer-loop): stop ambient ANTHROPIC_API_KEY from hijacking claude author shots

Three gen-2 launches died fast with 'agenticGenerator: author shot exited
with code 1' and no stderr on disk. Reproduced standalone: under the run's
dotenvx env (agent-state.env) the claude CLI prefers the injected
ANTHROPIC_API_KEY over the claude.ai login and exits 1 — 'API Error: 400
You have reached your specified API usage limits' (key org capped until
2026-08-01). Same command with the var unset returns rc=0.

- runHarness seam: strip ANTHROPIC_API_KEY/AUTH_TOKEN/BASE_URL from the
  shot subprocess env (claude harness only; rest of the run untouched).
- onShotCompleted seam: persist every shot receipt + bounded stdout/stderr
  tails to <outDir>/proposer-shots/ so the next failure names its cause.
Resolution notes:
- src/lifecycle: accept main's deletion (#564 unified activation); drop the
  branch's lifecycle extensions and the two superseded rigs that consumed them
  (bench/src/self-improve-swe.mts, find-lift-swe.mts) — swe-arena supersedes both.
- src/improvement: main's evolved relands win as the base; re-add the branch's
  rollout-policy surface, optimizer-method prompt, typed distiller findings,
  durable-run raw-trace default, and the shared-client mcpServeVerifier.
- src/runtime/stdio-mcp-client: port main's #508 async stdin-EPIPE fast-fail
  into the shared connection (regression caught by tests/mcp-serve-verifier).
- bench: main's published-package layout kept (no bench lockfile — installs via
  the root pnpm workspace); add agent-knowledge dep; keep the branch's
  structural rigs; teach run-package-tests.mjs to partition node:test vs vitest.
- docs/api regenerated via docs:api; examples/open-source-skill-lifecycle
  removed with the lifecycle module.

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Auto-approved drewstone PR — 1c6212a6

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-20T23:22:48Z

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Value Audit — better-approach-exists

Verdict better-approach-exists
Concerns 8 (1 medium-concern, 3 low, 4 weak-concern)
Heuristic 0.2s
Duplication 0.0s
Interrogation 515.5s (2 bridge agents)
Total 515.7s

💰 Value — better-approach-exists

A coherent, mostly in-grain change that closes the self-improvement loop end-to-end (typed SWE harness + improve() in the optimizer seat + the library surfaces it needs), but it ships one clear in-package duplication: createStdioToolServer is a verbatim copy of createMcpServer's JSON-RPC loop that c

  • What it does: (1) Adds a 'rollout-policy' surface to improve() so the held-out gate can tune the existing StructuralRolloutPolicy dials {k, repairRounds, testgen} that src/runtime/structural-rollout.ts already reads (that file's own docstring at line 24 anticipated 'may later tune StructuralRolloutPolicy as an optimizable surface'). (2) Adds a driverLoopGenerator CandidateGenerator that puts an LLM driver on th
  • Goals it achieves: Make the runtime's own inference dials (structural-rollout) and code reachable from the ONE held-out-gated improvement verb, so self-improvement measures real architectures (solo vs supervisor, cheap+verify vs frontier) instead of assuming them — and so a built MCP server, once verified, is served live during scoring rather than only probed. Once merged, improve() can tune k/repairRounds/testgen u
  • Assessment: Mostly in the grain of the codebase. The new code correctly reuses the canonical seams: driverLoopGenerator rides runBrainLoop/ToolLoopChat (not a new loop); the stdio and HTTP MCP paths share sanitizeMcpToolSchema; mcp-serve-verifier was properly refactored to delegate to connectStdioMcp; the 'local' sandbox backend slots into the existing resolveSandboxClient switch alongside sandbox/bridge/rout
  • Better / existing approach: Searched: (a) grepped canonical-api.md for the documented recommendation on rollout-policy — line 89 (unchanged by this PR) says 'Workflow and rollout-policy files are code/config changes; use the code surface plus agent-eval's parameterSweepProposer for JSON sweeps'; parameterSweepProposer is a real export (docs/api/primitive-catalog.md:1336, 'Config/parameter-level proposer for FAPO's middle esc
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 2
  • Bridge warning: opencode/kimi-for-coding/k2p7: bridge stream ended without value-audit content

🎯 Usefulness — sound-with-nits

Coherent extension of the established improve()/selfImprove spine — rollout-policy surface wired end-to-end, local-MCP pseudo-box + memory server each have reachable consumer surfaces, and the swe-arena outer loop drives the existing RSI verb from a documented bench entrypoint; only minor doc/versio

  • Integration: All new public surface routes through src/improvement/index.ts and src/runtime/index.ts. The 'rollout-policy' surface is wired into defaultGeneratorFor (improve.ts:233), materializeImprovementProfileCandidate (improve.ts:565), and consumed by bench/src/live-improve-campaign.mts:543 and live-improve-campaign-mbpp.mts:543. localSandboxClient is reached through resolveSandboxClient({backend:'local'})
  • Fit with existing patterns: Matches every established pattern. improve() is the documented one-verb RSI facade (CLAUDE.md, docs/canonical-api.md:89); rollout-policy is added as a fourth built-in default generator in the same shape as prompt/skills/memory. localSandboxClient sits alongside inlineSandboxClient/inProcessSandboxClient as another pseudo-box adapter behind the same resolveSandboxClient switch (resolve-sandbox-clie
  • Real-world viability: The outer-loop has the right hardening for realistic use: serialized-judge.ts provides a mutex + retry around the official SWE-bench evaluator, dispatchDeadlineMs/costGuardRatio bound per-cell spend, the promotion gate is operator-gated by design ('holdout-approval-required', outer-loop.mts:1328-1334), and the change-space verifier (changeSpaceViolations, outer-loop.mts:130) is enforced both in th
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 1

🔎 Heuristic Signals

🟡 Cruft: console debug added bench/scripts/run-package-tests.mjs

+console.log(

🟡 Cruft: todo added bench/src/swe-arena/parity.test.mts

    • TODO(operator approval): the full 12-instance ARM parity re-run (typed path

🟡 Cruft: magic number added bench/src/supervisor-arena.mts

  •    const inContainerSecs = Math.ceil(dockerTimeoutMs / 1000) + 2
    

💰 Value Audit

🟠 createStdioToolServer duplicates createMcpServer's JSON-RPC loop without delegating to it [duplication] ``

src/mcp/tool-server.ts:73-174 (createStdioToolServer) and src/mcp/server.ts:155-309 (the serve loop inside createMcpServer) are two independent implementations of the same newline-delimited JSON-RPC 2.0 stdio MCP server in the same package: identical PROTOCOL_VERSION ('2024-11-05'), identical handle/serve/stop structure, and byte-identical rpcResult/rpcError/writeResponse helpers (tool-server.ts:176-195 ≈ server.ts:311-330). The tool-server.ts docstring even says 'Extracted from createMcpServer

🟡 rolloutPolicyProposer hand-rolls the sweep the substrate's parameterSweepProposer already provides [better-architecture] ``

docs/canonical-api.md:89 (unchanged by this PR) recommends for rollout-policy: 'use the code surface plus agent-eval's parameterSweepProposer for JSON sweeps', and parameterSweepProposer is a shipped export (docs/api/primitive-catalog.md:1336). src/improvement/rollout-policy.ts:146-211 (enumerateNeighborPolicies + rolloutPolicyProposer) hand-rolls the equivalent bounded single-dial sweep for the three rollout dials. Most of rollout-policy.ts is needed regardless (serialization, normalize, profil

🟡 PR bundles many independently-shippable library surfaces with a large bench suite [proportion] ``

113 files / +23.5k/−5.4k. The library surfaces (rollout-policy, memory server, stdio MCP client, local sandbox client, driver-loop generator, campaign-otlp) are each independent enablers for different facets of the self-improvement loop, and the bench side adds a 43-file swe-arena harness plus david-goliath/supervisor-arena/swe-stream/live-campaigns rigs. Each library piece is coherent on its own and the bench rigs are separate experiments; a reviewer assessing any one piece must absorb all of t

🎯 Usefulness Audit

🟡 index.ts docstring overstates driverLoopGenerator wiring [ergonomics] ``

src/improvement/index.ts:11 and the driver-loop-generator.ts header describe driverLoopGenerator as 'default for tool/mcp', but defaultGeneratorFor (src/improvement/improve.ts:221-239) only returns defaults for prompt/skills/memory/rollout-policy. Tool/mcp/hooks/subagents still throw the ConfigError requiring an explicit opts.generator. The live behavior is fail-loud and safe; the comment overstates integration. Either wire it into defaultGeneratorFor or correct the docstring to 'pass via opts.g

🟡 bench pins agent-knowledge major 1 while runtime pins major 4 [robustness] ``

bench/package.json:45 adds '@tangle-network/agent-knowledge: ^1.11.1' (resolves to 1.12.1, which transitively pulls agent-eval@0.115.3 — pnpm-lock.yaml:723/1864), while the workspace runtime declares '^4.1.0' (resolves to 4.1.0, agent-eval@0.122.8). bench/src/swe-stream.mts:67 imports {applyKnowledgeWriteBlocks, buildKnowledgeIndex, initKnowledgeBase, searchKnowledge} directly from this package; under the bench's own declared range, the v1 API shape may not match those names. Likely needs '^4.x'


What this audit checks

It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.

Pass What it asks
Heuristic Vague title? Whitespace-only or cruft-bearing diff? (content signals only)
Duplication Do added function/class names already exist elsewhere in the repo?
Value Audit What does it do? What goal does it achieve? Is it good? Better architecture or already-exists?
Usefulness Audit Does it integrate and fit? Will it hold up in real use and actually get used?

Findings are concerns, not blocks — the human reviewer decides what to do with them.

value-audit · 20260720T233312Z

@tangletools

Copy link
Copy Markdown
Contributor

❌ Needs Work — 1c6212a6

Review health 100/100 · Reviewer score 0/100 · Confidence 95/100 · 42 findings (3 high, 11 medium, 28 low)

glm: Correctness 0 · Security 0 · Testing 0 · Architecture 0

Reviewer score is advisory once the run is complete and the verdict has no blockers.

Full multi-shot audit completed 8/8 planned shots over 113 changed files. Global verifier still owns final merge decision.

Blocking

🔴 HIGH Model-emitted Python runs on host with no jail (security regression vs every sibling benchmark) — bench/src/humaneval-object-ablation.mts

runPython writes the program to a mkdtempSync dir and execs python3 <file> directly on the host with only an 8s timeout — NO docker container, NO --network=none, NO cpu/mem caps. The CAPABILITY arm exposes this as a run_python tool to the model (line 109-128), so the worker can emit arbitrary Python that runs with the operator's full filesystem and network. Every sibling benchmark added in this PR jails model-emitted Python: supervisor-arena.mts line 148 (`docker run --rm --network=none --cpus=1 --memo

🔴 HIGH Phase B judge errors become permanent false verdicts (regression from old swe-structural-judge-policy) — bench/src/swe-structural.mts

Lines 597-608: the try/catch around env.adapter.judge sets hiddenResolved=false AND judgeSkipped='judge-error: ...' AND THEN appends the row to OUT. On resume, judged = loadRows(OUT) (line 584) loads ANY row regardless of judgeSkipped, and if (judged.has(id)) continue (line 590) skips re-judging. The deleted code explicitly commented 'Fail without appending: a transient official-scorer error mus

🔴 HIGH KeyProvider never wired through localSandboxClient — secret-provisioning plumbing is dead code via the 'local' backend — src/runtime/local-sandbox-client.ts

materializeLocalMcp(profile) is called with no opts.keys, and LocalSandboxClientOptions (lines 30-39) has no keys?: KeyProvider field. resolveSandboxClient's 'local' branch (resolve-sandbox-client.ts:103-111) only forwards {router, maxTurns, temperature, profile}, also with no keys. Grep confirms this is the ONLY materializeLocalMcp call site in src. Impact: any profile declaring metadata.secretEnv on an MCP server hits resolveSecretEnv's fail-closed throw at create() — 'declares secret env (...) but no KeyProvider was supplied — refusing to boot an external server keyless'. The exported KeyProvider/envKeyProvider/resolveSecretEnv/secretEnvOfMcpServer

Other

🟠 MEDIUM HTTP_CODE regex anchored to end-of-stdout assumes dotenvx banner position — bench/src/swe-arena/capacity.ts

The probe tests /HTTP_CODE=200\s*$/.test(res.stdout) — anchored to end-of-string with only trailing whitespace. The comment says 'dotenvx writes its injection banner to the same stdout stream'. If dotenvx's banner lands AFTER the curl marker (e.g. on a newer dotenvx version), the regex fails and the gate reports no-capacity even when the endpoint is healthy — causing the run to abort or hold for hours. Same pattern in diagnosis-ensemble.ts line 299 (/HTTP_CODE=(\d{3})\s*$/). The author tested the current banner position, but the assumption is fragile. Fix: match anywhere in stdout (/HTTP_CODE=200\b/) since the marker is unique.

🟠 MEDIUM curl --max-time computed as timeoutMs/1000 - 20 can go to 0 or negative — bench/src/swe-arena/diagnosis-ensemble.ts

Line 285: --max-time ${Math.ceil(timeoutMs / 1000) - 20}. With the default 600_000ms this is 580s, fine. But if a caller passes a smaller timeoutMs (<20000), the value becomes 0 or negative, and curl behavior is undefined (recent curl treats <=0 as 'no timeout' which removes the safety bound, while older curl errors out). The 20s gap is meant to leave room for curl's own startup, but the math has no floor. Fix: Math.max(30, Math.ceil(timeoutMs / 1000) - 20).

🟠 MEDIUM Hardcoded user-specific paths in default config (portability + hygiene) — bench/src/swe-arena/outer-loop.mts

outer-loop.mts hardcodes DEFAULT_HH_SCRATCHPAD = '/tmp/claude-1000/-home-drew-code-supervisor-lab/f06fd156-042a-4ef9-bd88-f2ec7f52b90c/scratchpad/hh', defaultRound4Config hardcodes loopsRepo='/home/drew/code/loops', roundsDir='/home/drew/code/supervisor-lab/.evolve/rounds', secretsDir='/home/drew/company/devops/secrets'. arms.ts DEFAULT_LOOPS_REPO='/home/drew/code/loops'. swe-code-improve.mts REPO_ROOT/SWE_MAIN_ROOT default to /home/drew/code/... and the WORKTREE_DIR/RUN_DIR defaults to a specific /tmp/claude-1000 session-id path. All are env-overridable, so this is acceptable for in-house benchmark tooling but breaks any external user and creates implicit dependency on a specific host layout. Also: fixtures/analyze.py hardcodes the same HH path. Recommend failing loud when defaults are us

🟠 MEDIUM serialized-judge conflates missing patch file with empty patch — bench/src/swe-arena/serialized-judge.ts

Line 213: const patch = await readFile(patchPath, 'utf8').catch(() => ''). If the file is missing (e.g. an arm crashed before writing its patch and passed a stale path), the catch silently returns '' and the function short-circuits with {resolved:false, note:'empty-patch'}. A real empty patch IS a true negative, but a missing-file IS an operator error worth aborting on. Fix: distinguish ENOENT (throw) from empty content (short-circuit).

🟠 MEDIUM Defined-in path prefix agent-runtime-swe/src/ is a non-existent directory; 4,481 regressions across every changed file — docs/api/runtime/environment-provider.md

Diff swaps Defined in: [runtime/environment-provider.ts:90](…/src/runtime/environment-provider.ts#L90)Defined in: [agent-runtime-swe/src/runtime/environment-provider.ts:90](…/src/runtime/environment-provider.ts#L90). Same swap on every 'Defined in' line in all 12 files (rg counts: runtime.md 1576, index.md 1196, intelligence.md 431, mcp.md 679, profiles.md 182, agent.md 139, platform.md 104, primeintellect.md 91, analyst-loop.md 52, environment-provider.md 30, testing.md 1 — total 4,481). agent-runtime-swe/ does not exist in this repo (ls agent-runtime-swe → 'No such file or directory'; the actual source is at src/runtime/environment-provider.ts). It is the local name of a sibling SWE-bench checkout referenced in bench/src/*.mts as ~/code/agent-runtime-swe. The source-link U

🟠 MEDIUM isAnalystFinding hard-pins schema_version === '1.0.0', forward-incompatible by construction — src/improvement/findings.ts

isAnalystFinding requires o.schema_version === '1.0.0'. Any future bump of the AnalystFinding schema (e.g. to 1.1.0 or 2.0.0) would make EVERY real finding fail the guard and get re-enveloped via makeFinding — losing the original finding_id, produced_at, evidence_refs, and any typed metadata, then double-stamped as analyst_id='lifted-seed'. The resulting metadata.raw preserves the bytes but consumers downstream of toAnalystFindings (curators, build prompts, trace-analyst apply) would silently get noise where they previously got typed findings. Fix: accept any string schema_version starting with '1.' (or parse semver and accept ≥1.0.0), or drop the version check and rely on the structural fields. Brittle-by-design guard at findings.ts:32.

🟠 MEDIUM Silent default change: real runDir now flips analyzeGeneration to raw-trace distiller — src/improvement/improve.ts

Previously, when a caller passed runDir but not rawTraceContext and not analyzeGeneration, the default findings producer was generationFailureDistiller (the per-cell failure digest). The new defaultDistillerFor returns rawTraceDistiller whenever opts.runDir !== undefined && !opts.runDir.startsWith('mem://') — i.e. nearly every real run. Findings change shape: from {scenario, composite, notes, error} metadata envelopes to raw-trace path pointers. Any caller (or downstream proposer) that consumed the old digest fields without an explicit analyzeGeneration will silently change behavior on upgrade. The change is documented in the JSDoc, but it is a default flip, not an opt-in. Fix: keep the digest as the default for one release and require rawTraceContext: true to opt into

🟠 MEDIUM Doc comments reference deleted src/lifecycle/memory.ts symbols; memory module lands orphaned — src/mcp/memory-bin.ts

memory-bin.ts:8 ('process memoryMcpServer (lifecycle/memory.ts) mounts into profile.mcp'), memory-bin.ts:12 ('Environment variables (the memoryMcpServer contract)'), and memory-server.ts:35 ('see memoryArtifactFromLessons') all reference symbols that lived in src/lifecycle/memory.ts (added in commit 13cb802). git ls-tree 1c6212a -- src/lifecycle returns NOTHING — the merge commit's own resolution notes ('src/lifecycle: accept main's deletion (#564 unified activation); drop the branch's lifecycle extensions') deleted the entire directory. grep across src/tests/examples for AgentMemorySpec, AGENT_MEMORY_FILE, memoryMcpServer, or memoryArtifactFromLessons outside src/mcp/memory* returns ZERO hits. So the published bin and the exported createMemoryToolServer have no in-repo consumer on

🟠 MEDIUM Zero test coverage for localSandboxClient and key-provider — fail-closed security paths untested — src/runtime/key-provider.ts

No test files exist for local-sandbox-client.ts (grep find src -name 'local-sandbox-client*' returns only the .ts) or key-provider.ts. The security-critical fail-closed branches in resolveSecretEnv (no provider → throw; missing key → throw) and secretEnvOfMcpServer (malformed metadata → throw) have NO test coverage. materializeLocalMcp paths uncovered: KeyProvider integration with secretEnv, multi-server spawn, name collision detection (lines 315-319), maxResultChars truncation (line 345), fail-closed cleanup of already-spawned c

🟠 MEDIUM Cancellation signal only checked at turn boundaries — brain call and tool execution run to completion after abort — src/runtime/local-sandbox-client.ts

hooks: { stopBefore: () => popts?.signal?.aborted === true } only fires at the TOP of each runBrainLoop turn (tool-loop.ts:154). The brain call (line 73: await brain(messages, tools)) and tool execution (line 80: mcp.call(name, args)) are never given the signal. A caller abort mid-inference or mid-tool-call waits one full router completion + one full MCP round-trip before the loop notices. This diverges from inlineSandboxClient ([lines 65-74](https://github.com/tangle-network/agent-runtime/blob/1c6212a6a2462fc37bce366821

🟠 MEDIUM connectStdioMcp orphans grandchild processes — SIGKILL only hits the immediate child — src/runtime/stdio-mcp-client.ts

spawn(spec.command, spec.args ?? [], { ...(spec.cwd ? { cwd: spec.cwd } : {}), stdio: ['pipe','pipe','pipe'], env: {...} }) uses the default detached:false, and close() (line 194) calls child.kill('SIGKILL'). A built MCP server commonly spawns subprocesses (tsc --watch, esbuild, language servers); those grandchildren survive the parent's SIGKILL and leak. Across a 100-candidate eval each spawning an MCP server with a watcher, this leaks hundreds of processes. Fix: spawn with detached:true (the child becomes its own session leader) and kill the whole process group in close(): try { process.kill(-child.pid, 'SIGKILL') } catch { child.kill('SIGKILL') }.

🟡 LOW david-attribution.mts: TANGLE_API_KEY! non-null assertion yields confusing late error — bench/src/david-attribution.mts

const KEY = process.env.TANGLE_API_KEY! — the adjacent sibling david-goliath.mts:31 correctly does if (!KEY) throw new Error('TANGLE_API_KEY required'). With the non-null assertion, a missing key flows into the chat() fetch's authorization: \Bearer ${KEY}`as 'Bearer undefined', and the user sees a router 401 with no hint that the env var is missing. Replace!` with the same explicit throw.

🟡 LOW hev-improve defaults silently raised 12x: GENERATIONS 1->6, POPULATION 2->4 — bench/src/hev-improve.mts

Default budget changed from 2 candidate evaluations to 24. Comment justifies it ('1 generation never exercises the GEPA Pareto/combine path') but any operator/CI invocation that previously relied on the documented defaults will see ~12x runtime and dollar cost on the next run. Either bump major version of the script's contract, gate the new defaults behind a flag (e.g. FULL=1), or print the cell-count estimate + an explicit cost warning at startup before the first model call. The cellsMax line in swe-improve.mts already shows the pattern worth copying here.

🟡 LOW stream-observe.py: HTML injection via </script> + missing with + JSONDecodeError kills whole load — bench/src/stream-observe.py

tpl.replace('__DATA__', json.dumps(data)) substitutes raw JSON into a <script> block. json.dumps does not escape <, so any data field containing </script> (e.g. an instance-id, an error message, or a directory name in the ledger/events/plan inputs) terminates the script tag early and breaks the page. The curve function slices err to 16 chars (longer than </script>'s 9 chars) and embeds os.path.basename(sd) as dir — both operator-controlled but the failure mode is silent. Fix: json.dumps(data).replace('</', '<\\/'). Also: open() calls have no with/close (file-handle leak on long runs); jl(p) catches FileNotFoundError but a single malformed line throws JSONDecodeError that aborts the whole load — wrap with per-line try/except or filter(None, ...). Trailing newline in

🟡 LOW supervisor-arena has no resume support — partial run + restart duplicates rows — bench/src/supervisor-arena.mts

Phase A appends to ${out}.phaseA (line 751) and Phase B appends to out (line 777). Unlike swe-structural.mts (which resume-skips via loadRows) and swe-stream.mts (which resume-skips via doneIds), supervisor-arena.mts never reads existing rows before running. A crash + restart produces duplicate rows in either file. The summary stats (graded.filter(...)) would then double-count. Documented as 'append-only' implicitly but no explicit warning. Fix: either add resume like the other two, or print a warning if OUT/phaseA already

🟡 LOW bootstrap-meta.mts CLI: missing-mode call gives confusing TypeError instead of usage — bench/src/swe-arena/bootstrap-meta.mts

const [mode, ...rest] = process.argv.slice(2) then if (mode === '--task-meta') ... else if (...) ... else { console.error(usage); process.exit(2) }. When invoked with no args, mode is undefined, falls through every branch, and the else block correctly prints usage. But: with --calibrate and only one positional, rest[1] is undefined and the script proceeds with workDir=undefined into mkdir(undefined, ...), which throws ENOENT rather than the clean usage message the explicit check promises. Add the explicit verifyDir || workDir guard before the destructure or validate length.

🟡 LOW fixtures/analyze.py and fixtures/worker-tokens.json have no trailing newline; analyze.py has hardcoded /tmp/claude-1000 path — bench/src/swe-arena/fixtures/analyze.py

analyze.py line 3 hardcodes HH='/tmp/claude-1000/-home-drew-code-supervisor-lab/f06fd156-042a-4ef9-bd88-f2ec7f52b90c/scratchpad/hh' — a specific user/session path that only works on one host. It's a fixture (analysis script for the captured ledger), not load-bearing library code, but committing it with a hardcoded path makes reproduction brittle. worker-tokens.json has no trailing newline (line 42 }). Low priority since these are fixture artifacts.

🟡 LOW Promotion-gate wouldKeep re-checks staticArt.violations/typecheckOk already encoded in verdict — bench/src/swe-arena/outer-loop.mts

Line 1321: wouldKeep = verdict === 'accepted' && staticArt.violations.length === 0 && staticArt.typecheckOk. But decideVerdict already rejects on violations.length > 0 (rejected-out-of-space) and the static-gate cell already gates on typecheckOk for its score (line 1191). decideVerdict does NOT independently check typecheckOk — so this extra clause is the ONLY place a tsc failure blocks wouldKeep. That's actually meaningful (a tsc-failing static cell still scores 0 → composite-mean gate fails), but the redundancy w

🟡 LOW run-supervisor.mjs: brittle journal kind-count + unhelpful id-parse failure — bench/src/swe-arena/run-supervisor.mjs

journal.filter((l) => l.includes('\"kind\":\"spawned\"')).length is a substring count over JSONL — a settled event whose goal text or report quotes the literal \"kind\":\"spawned\" (e.g. in an error message about a spawn failure) double-counts. Use JSON.parse(l)?.kind === 'spawned' with try/catch. Separately, if (!id) { console.error('[driver] could not parse supervisor id'); process.exit(1) } discards the spawn tool's actual return text, which is the only diagnostic the operator has when the format changes — print text in the error.

🟡 LOW Patch-capture idempotency contract diverges between sibling bench tools — bench/src/swe-emit-patch.mts

swe-emit-patch.mts:80 keeps the LATEST non-empty diff (if (d.stdout.trim()) capturedPatch = d.stdout); swe-improve.mts proxy.score keeps the FIRST (if (!capturedPatch.trim() && diff.stdout.trim()) capturedPatch = diff.stdout); swe-local-proof.mts:106 documents 'keep the FIRST non-empty patch; never overwrite it with a later empty read'. All three are defensible (emit-patch wants accumulated refinements; the others want the first-success baseline) but the divergence is undocumented at the call site of swe-emit-patch and easy to misread as a bug when refactoring across the trio. Add a one-line comment in swe-emit-patch.mts explaining why its contract differs, or unify.

🟡 LOW Zero-usage telemetry floor under-reports cost on affected cells (disclosed but unguarded) — bench/src/swe-improve.mts

zeroUsage && hasPatch branch reports Math.max(r.usd ?? 0, 0.0001) USD and Math.max(r.tokens.input ?? 0, 1)/output tokens when both token fields come back zero. The comment correctly notes the lift metric (judge-derived) is unaffected, but cost rollups will undercount those cells by however many real tokens were spent. There is no per-cell counter of how often the floor fired (only a log line) so a post-run cost report cannot reconstruct the missing spend. Add a flooredCellCount to the run summary or surface a warning at RESULT print time so the disclosed under-count is auditable after the fact.

🟡 LOW absoluteSweTempDir() safety guard removed at one site, kept at 4 others — bench/src/swe-jail.ts

The diff swaps absoluteSweTempDir() for tmpdir() in runPyInJail's mkdtempSync parent. The bootstrap-meta.mts comment justifies the change with 'a reboot wiped /tmp and took task-meta.json with it' — but absoluteSweTempDir() (bench/src/swe-temp.ts:5) only checks tmpdir() is absolute (catches the TEMP=./relative footgun). It does NOT change the directory used. So removing it does not survive reboots any better; it only loses the relative-path guard. grep shows absoluteSweTempDir still used at swe-bench-env.ts:23,318,339 and swe-bench-env.test.ts:19,101,104 — this is the only call site that lost the guard. Either keep the guard here (it's a 1-line wrapper) or delete swe-temp.ts entirely and migrate all 5 sites. Half-migrating leaves an inconsistency.

🟡 LOW Comment wording slightly imprecise about non-swe-arena test files — bench/vitest.config.ts

Comment calls the other bench *.test.mts files 'tsx-run assertion scripts (see HARNESS.md)'. In reality, scripts/run-package-tests.mjs runs them via node --test --import tsx (node:test runner with tsx loader), and HARNESS.md line 139 echoes that. Only gate.test.mts is invoked as a direct tsx script (HARNESS.md:140). Wording could read 'node:test scripts run via tsx loader' for precision. Harmless — intent (they're not vitest suites) is correct.

🟡 LOW Config not surfaced in package.json scripts or HARNESS.md — bench/vitest.config.ts

The comment documents ../node_modules/.bin/vitest run as the invocation, but bench/package.json has no script entry (e.g. "test:vitest") and HARNESS.md does not reference this config. The partition script (run-package-tests.mjs) discovers it implicitly via vitest auto-discovery from cwd=bench/, so pnpm test still works. A short HARNESS.md note + a script entry would make direct-invocation ergonomic, but this is a doc-gap, not a bug.

🟡 LOW agent.md allowCreateForKinds union: pure enum-sort change, no semantic delta — docs/api/agent.md

Only non-path content change in agent.md is reordering the rollout-policy literal earlier inside the readonly (...)[] union (now positioned after agent-profile, before knowledge.wiki). No semantic change — the union membership is identical, only the source-order rendering shifted. Flagged only because it is the entire non-path delta in this 280-line file and reviewers should not waste time hunting for behavior change. No action required.

🟡 LOW Inherited-Error constructors leak agent-runtime/node_modules/.pnpm/typescript@5.9.3/... into runtime.md — docs/api/runtime.md

The two new McpSpawnFault(...) inherited-constructor blocks render their Defined in: line as agent-runtime/node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1082 — a node_modules path under a DIFFERENT sibling prefix (agent-runtime/, not agent-runtime-swe/) than the surrounding class declaration on line 417 (agent-runtime-swe/src/runtime/stdio-mcp-client.ts:57). Two bugs in one: (a) the doc leaks the internal pnpm-store path and the TypeScript package version, exposing toolchain internals on a public reference page; (b) the inconsistent prefix inside one class block is direct evidence that typedoc captured the CWD-relative path a

🟡 LOW FNV-1a fold to 128-bit trace id is not a real 128-bit hash — src/improvement/campaign-otlp.ts

foldTo32Hex(s) = foldTo16Hex(s) + foldTo16Hex(s + '::trace') and foldTo16Hex(s) = fnv1a(s) + fnv1a(s + '::salt'). The result is 4×32-bit FNV-1a hashes concatenated — 128 bits in width but with far less than 128-bit collision resistance because FNV-1a chunks are independent rather than jointly diffused. For a closed per-run trace namespace this is fine, but if two unrelated campaigns ever share a trace store (or the OTLP output gets merged globally), distinct cell paths could collide on trace_id and conflate spans. Not exploitable but worth a comment; if global uniqueness ever matters, swap FNV-1a for a 128-bit hash (e.g. md5 of the UTF-8 bytes) without changing the API.

🟡 LOW driverLoopGenerator sessionsUsed increments before await; abort/error leaves count off by one vs. observable sessions — src/improvement/driver-loop-generator.ts

sessionsUsed += 1 runs before await run({...}). If run throws (the harness spawn faults), the count is incremented for a session that never produced output, and the loop will reject one subsequent legitimate instruction with the budget-exhausted message. The contract is sessionCap = max(1, maxShots) so this only matters at the boundary; consider decrementing in a catch or moving the increment to after a successful spawn. Not a correctness issue for the green-path tests.

🟡 LOW materializeImprovementProfileCandidate short-circuits empty rollout-policy winner without asserting baseline was empty — src/improvement/improve.ts

For surface: 'rollout-policy', if (winner === '') return undefined. The comment ties this to baselineSurfaceFor returning '' when the profile has no policy — but nothing re-validates that assumption at apply time. If a future code path ships a literal '' winner for a profile that DID have a policy extension, the candidate would silently be undefined and the user-visible ImprovementResult.candidate.profile would fall back to the unchanged baseline, dropping a real promotion without any signal. Tighten by either asserting profile.extensions?.[ROLLOUT_POLICY_EXTENSION] === undefined here or having the proposer itself refuse to ship '' when the profile had a policy.

🟡 LOW mcp-serve-verifier dropped the local timeoutMs default; behavior unchanged but coupling is implicit — src/improvement/mcp-serve-verifier.ts

The previous implementation declared const timeoutMs = spec.timeoutMs ?? 30_000 and used it both for the spawn timeout and the error message. The new implementation passes spec.timeoutMs through to connectStdioMcp only when defined, and connectStdioMcp defaults to 30_000ms internally. Net behavior is identical, but the coupling is now implicit — if connectStdioMcp ever changes its default, this verifier changes too. Minor; consider re-stating the default in the JSDoc or as a local constant.

🟡 LOW rollout-policy rotation uses raw modulo, can go negative for negative generation — src/improvement/rollout-policy.ts

const start = (ctx.generation * cap) % neighbors.length. In JS, % preserves the sign of the dividend: a hypothetical negative ctx.generation (or cap greater than neighbors.length interacting with future changes) yields a negative start, which then propagates into neighbors[(start + i) % neighbors.length] and indexes undefined. agent-eval's loop contract starts at generation 0 and increments, so this can't fire today — but defensive code would be const start = ((ctx.generation * cap) % neighbors.length + neighbors.length) % neighbors.length. Robustness, not a live bug.

🟡 LOW JSONL line number in error is wrong when blank lines precede the bad row — src/mcp/memory-server.ts

Lines 243-257 split the trimmed file on '\n', then .map(line => line.trim()).filter(line => line.length > 0), then .map((line, i) => coerceMemoryItem(parsed, ${path}:${i+1})). The filter runs BEFORE the index is assigned, so i+1 is the index in the POST-FILTER array, not the file's actual line number. Reproduced via tsx: write '{"id":"a","text":"ok"}\n\n{"id":"b"}\n' (bad row on file-line 3) -> error reports '/tmp/...:2: text must be a non-empty string'. Leading blanks make it worse: '\n\n{valid}\n{bad}\n' (bad on file-[line 4](https

🟡 LOW readMemoryItemsFile silently accepts a top-level JSON object as a 1-row store — src/mcp/memory-server.ts

Line 232 branches on trimmed.startsWith('['') only. Anything else falls through to the JSONL path, where each line is JSON.parsed and coerced. So a file containing exactly {"id":"a","text":"x"} (a single JSON OBJECT, not an array) parses as a 1-row JSONL store and returns successfully — reproduced via tsx ('FAIL: object-as-file did not throw'). Meanwhile parseMemoryItems (used for AGENT_MEMORY_ITEMS) correctly throws 'expected a JSON array of memory items' on the same payload (memory-server.test.ts:138-141 covers that path). The two ingestion surfaces (file vs env) have inconsistent validation tightness for the same logical payload. Impact: a typo that drops th

🟡 LOW serve() does not .catch() handler rejections; unhandled-rejection risk if handle() ever throws — src/mcp/tool-server.ts

Line 154-156: void handle(parsed).then((response) => { if (response) writeResponse(output, response) }) has no .catch. Today handle() cannot reject — initialize/tools/list/notifications/initialized only return; tools/call wraps the handler in try/catch and maps to rpcError. But if any future change adds an unguarded throw inside handle (e.g., a new method dispatch), Node will print 'Unhandled promise rejection' and the request silently disappears with no JSON-RPC error response. The same shape exists in server.ts:282 (extraction-faithful) so this is not a regression — flagging because the new core is the one intended for reuse. Fix: `.catch((err) => writeRespo

🟡 LOW tool-server.ts duplicates McpTransport / JsonRpcMessage / JsonRpcResponse from server.ts — src/mcp/tool-server.ts

tool-server.ts:29-48 defines McpTransport, JsonRpcMessage, JsonRpcResponse with the SAME shape as server.ts:124-144. The extraction comment ('Extracted from createMcpServer (server.ts)') explains why, but index.ts:121-128 re-exports server.ts's versions as the public Mcp* types while tool-server.ts keeps its own private copies (only StdioToolDescriptor is aliased at index.ts:146). Result: two parallel type identities for the same wire types. A caller that already holds a server.ts JsonRpcMessage and passes it to createStdioToolServer's handle() works structurally but TypeScript treats them as distinct. Not a runtime bug; future drift risk. Fix: import the three types from ./server in tool-server.ts and re-export them, or move them into a shared jsonrpc.ts that both consume.

🟡 LOW Loose as-unknown-as casts on SandboxEvent/SandboxInstance hide type drift — src/runtime/local-sandbox-client.ts

yield { type: 'llm_call', data: {...} } as unknown as SandboxEvent and the returned object as unknown as SandboxInstance (line 108). SandboxEvent is just { type: string; data: Record<string, unknown>; id?: string } (verified in node_modules/@tangle-network/sandbox/dist/types-D2C72ySk.d.ts:1343) so the event cast is gratuitous. The SandboxInstance cast is a real lie: the returned object only has {id, streamPrompt, delete}, but SandboxInstance is a class with fs, prompt, events, trace, fork, watchProvisioning, task, etc. Any kernel code that reaches for those (e.g. runLoop persistence or session fork) will TypeError at runtime with no compile-time warning.

🟡 LOW Metering lost on mid-loop failure — partial cost never emitted as llm_call event — src/runtime/local-sandbox-client.ts

costUsd accumulates inside the chat wrapper (line 74) as turns succeed, but the llm_call event (lines 90-95) is only emitted AFTER runBrainLoop resolves. If runBrainLoop throws after N successful turns (brain error, tool-loop exhaustion with a thrown exec, etc.), the generator throws and the kernel sees neither llm_call nor result — the cost of the N successful turns is silently unbilled. inlineSandboxClient has the same shape (no finally emit), so behavior is consistent, but for a same-host backend where brain calls

🟡 LOW Collision error message misreports single-server collisions as 'across servers' — src/runtime/stdio-mcp-client.ts

After sanitization (line 314: t.name.replace(/[^a-zA-Z0-9_-]/g, '_')), two tools within the SAME server — e.g. 'my-tool' and 'my.tool' — both map to 'my_tool' and trip the routes.has(name) check. The thrown message says 'namespaced tool X collides across servers — rename the profile.mcp keys', which is wrong for a within-server collision and sends the operator after the wrong fix. Fix: track the source server key in the routes map and branch the message: 'collides with another tool in server X' vs 'collides across servers X and Y'.

🟡 LOW strategy-author prompt grows by ~1.1KB — risks squeezing thinking-model maxTokens output budget — src/runtime/strategy-author.ts

strategyAuthorMethod (imported from ../improvement/optimizer-prompt) is a ~1100-char seven-step method appended to the user prompt on top of strategyAuthorContract (~2KB) + lossesJson (variable). For thinking models with maxTokens set (AuthorStrategyOptions.maxTokens), the input growth reduces available output tokens and could push the authored module past the maxTokens ceiling — manifesting as the 'no code block in the author reply' throw at line 172. Not a bug; flag as a regression risk worth a smoke before merge.


tangletools · 2026-07-21T00:20:06Z · trace

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ 3 Blocking Findings — 1c6212a6

Full multi-shot audit completed 8/8 planned shots over 113 changed files. Global verifier still owns final merge decision.

Full immutable report for this review: trace

Summary comment for this run: full summary


tangletools · 2026-07-21T00:20:06Z · immutable trace

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants