Skip to content

perf: 2026-10-09 performance-hunt findings, one commit per finding - #311

Merged
jkyberneees merged 53 commits into
mainfrom
fix/perf-hunt-2026-10-09
Oct 9, 2026
Merged

jkyberneees merged 53 commits into
mainfrom
fix/perf-hunt-2026-10-09

Conversation

@jkyberneees

@jkyberneees jkyberneees commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Cumulative fix branch for the 2026-10-09 performance hunt (11 module sweeps, every finding re-validated against source before fixing). 41 of 43 findings are addressed, one commit per finding, each with a TestRED_* regression test; the two left out are operator design decisions (below). No security property was weakened: every scanner, redactor and classifier change ships with an old-vs-new equivalence test, and the batched tool-output scan applies only to the linear local rule scanner.

Findings list, per-area fix reports and the six adversarial review reports live under .claude/perfhunt-2026-10-09/ (gitignored, local).

Highlights (before → after)

Area Change Before After
tools wrapUntrusted per element → one guard scan group + one audit ingest per tool result; audit append is a single O_APPEND write once validated 100-line diff: 200 audit appends, each re-reading the log twice 1 ingest; 50 appends read the log twice in total
danger required-literal prefilter in ScanInjection (derived from the compiled patterns, verdicts identical) 256 KiB: 158 ms 23 ms
skills package-level stopwords/replacer, precomputed per-skill sets; local-guard verdict memo by SHA-256 MatchLazySkills 200 skills: 179 ms / 448 MB; NewSkillManager: 294 ms 1.6 ms / 0.19 MB; 22 ms warm
session strings.Builder for vector text; prompt-redaction memo; lstat revision stamp; lazy index.json; lazy List stats 300-turn text: 600 allocs; 21 prompt redactions per save; full Load per step 1 alloc; 1 redaction; 0 loads
loop one-pass trim compaction; in-place checkpoint merge; effect-evidence memo; queue view trim 2500 groups: 26 ms / 9804 allocs; merge 1000 records: 221 µs / 1 MB 0.22 ms / 24 allocs; ~1 µs / 0 allocs
runtimelog prune reads first, rewrites only when something expired; writer batches queued records full unbuffered rewrite on every CLI start; 1000 writes per 1000 records read-only scan; 1–2 writes
REPL bracketed paste redraws once; history appends one line 8 KB paste: 8192 redraws 1
telegram progress edits off the agent loop with latest-wins slot; retry_after honoured; shared keep-alive fallback transport 429 stalled the next LLM iteration up to 15 s; 1 TLS handshake per call bounded 3 s finish; 1 connection
serve ctx-aware bounded @-completion walk; per-connection WS write queue with delta coalescing; redaction outside the events lock; cached ETags; amortised rate-limiter gc goroutine + timer per frame under the conn mutex one writer per connection
redact per-pattern FindStringIndex gate 1 MiB clean text: 65.5 MB allocated 0.35 MB
misc byte-offset rune truncation (mcpclient/artifact/render), single-decode MCP read loop, bgproc ring reuse, AtomStore.List cache, diff LCS over the changed region only, stat-once sort in search/glob, windowed read_file skips hashing, session_search early exit

Coverage (package, base → branch)

Package Base Branch
cmd/odek 71.9% 72.6%
internal/loop 93.1% 93.1%
internal/session 87.5% 89.5%
internal/danger 92.1% 92.1%
internal/redact 92.2% 92.7%
internal/skills 90.4% 91.0%
internal/runtimelog 87.5% 88.7%
internal/bgproc 88.6% 89.1%
internal/telegram 89.9% 90.0%
internal/agent 77.4% 78.1%
internal/resource 89.2% 90.0%
internal/events 95.4% 95.5%
internal/mcpclient 94.2% 94.6%
internal/artifact 90.0% 91.0%
internal/render 68.4% 68.8%
internal/memory/extended 90.7% 90.8%
internal/logquery, internal/memory unchanged unchanged

Visible behaviour changes

  • Audit log: externally-sourced list results are recorded in ingest chunks of at most 64 elements (hash covers every element, every discovered path stays on a record) instead of one record per element; docs/SECURITY.md updated. A hit on the joined text alone no longer banners a group: only elements flagged on their own carry the notice (parity with per-element scanning).
  • search_files: files excluded by file_glob are filtered before classification and no longer listed in skipped (docs/CHEATSHEET.md).
  • SaveNoIndex leaves index.json alone when only UpdatedAt/token counters moved by under 2 s; listings may trail by under 2 s mid-turn (AGENTS.md).
  • Telegram: progress-bubble edits are asynchronous with a latest-wins slot; the last edit is flushed before the answer, bounded to 3 s; retry_after is honoured up to the existing 8 s cap (docs/TELEGRAM.md). The fallback transport now honours proxy env vars like the primary client.
  • artifact.TruncateRunes is exported; a negative n returns "" instead of panicking.

Skipped (design decisions, not attempted)

  • P24 MCP servers are respawned per serve REST run / WS connection. A ref-counted pool keyed on the approval hash changes process lifetime and trust scope.
  • P25 artifact_read re-hashes the whole artifact on every windowed read. The TOCTOU check is a safety property; only a cheaper freshness check would help.
  • L13 logquery.Read has no early exit on --limit; it also reports malformed/truncated/position metadata that --follow relies on.
  • L12 (partial) VectorIndex.Add still rewrites the gob store per final save; only Cleanup is batched (RemoveMany).
  • L18 docker CLI fork per sandboxed tool call (unmeasured).

Review

Six per-area adversarial Sonnet reviews ran against the merged commits; everything they found is fixed in the seven review follow-up commits at the top of the branch:

  • loop: effect-evidence memo now keyed on redact.Generation() (a secret registered mid-run was not re-redacted).
  • storage: prompt memo reads the generation before the scan (race with RegisterSecret); lstat revision stamp distrusted while younger than 50 ms or without an inode; audit memo compares mtime; a newline-less audit tail always gets a newline (a complete record that lost its terminator was merged with the next append — pre-existing).
  • serve: WS writer space channel initialised at construction (producers could wait on a nil channel until the write timeout).
  • runtime: AGENTS.md scan cache keyed on values (was %+v with pointer addresses) and only for the local scanner (a sidecar fallback was cached as a lasting acceptance); retry_after parsed as a number and clamped; progress finish bounded; paste terminator read in full.
  • tools: sidecar guard keeps per-element scanning (batching only for the local linear scanner); file_glob filtering documented. A 21 MB cmd/odek/odek binary that two commits had picked up was stripped by rebuilding the branch linearly; .gitignore now covers it.

Full-branch Opus review (whole-diff, cross-area): no security regressions; independently brute-forced the ScanInjection prefilter (5.8M checks, zero misses), re-ran the diff equivalence on 200k cases, confirmed every RED fails on base and no pre-existing test was loosened. Its findings are fixed on the branch: the timing-bound scan test (now relative, -race-safe), retry_after decoding in doJSON/doUpload, memo/batch eligibility decided by the guard type (guard.IsLocal) rather than the provider string, and no fallback progress post after finish gives up.

/code-review 311 medium: eight findings, all addressed: join-only banner floods (now parity), WS deadline measuring queue depth (now resets on writer progress), per-result audit resource cap (now 64-element chunks), stale retry_after across network errors (reset per attempt), repeated finish waits (early return), redundant search_files re-sort, dead promptUser helper. The render/artifact truncation duplicate was left as is: the two helpers differ on invalid UTF-8 and render's equivalence test pins its own behaviour.

CodeQL: one new alert (allocation size arithmetic in the bgproc ring) fixed by requesting capacity as a bounded size and growing it with slices.Grow instead of summing operands; the window and each write are clamped to a 1 GiB constant.

Verification

  • go vet ./... clean; gofmt -l clean on all touched files.
  • go test ./... -short -race -count=1 (the CI command) green locally except the pre-existing home-dependent danger test, which passes with HOME=/home/user.
  • ODEK_E2E=true go test ./cmd/odek/ -run 'TestE2E_|TestMCPClientE2E_' green.
  • Fuzz: FuzzAnalyzeBounded (20 s), FuzzPipeIntoShell (15 s) pass after the classifier changes.

🤖 Generated with Claude Code

jkyberneees and others added 30 commits October 9, 2026 11:55
P8: context-trim pass 2 memmoved the tail and rebuilt the protected map
per dropped group. It now scans forward once, compacting survivors in
place. Deep trim of 2500 groups: 26.2 ms, 9804 allocs, 3.1 MB -> 0.22 ms,
24 allocs, 45 KB. Drop order, tool counters, turn boundary shifting and
digest ordering are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P10: refreshEffectEvidence joined and redacted the whole mutation ledger
on every iteration. The redacted body is now cached keyed on ledger
length and newest entry, and reset at run start. Unchanged-ledger refresh
at 400 mutations: 98 allocs plus a full redaction pass -> memoized.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L16: refreshDigest cloned the whole pendingDropped queue per trim though
the extractive sketch reads a bounded prefix. It now takes a
capacity-clamped read-only view; queued records are never rewritten in
place. 20000-message queue: 4.4 MB per refresh -> under 512 KiB bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P9: MergeCheckpoint cloned the full durable transcript on every call (4+P
per iteration). The engine now keeps an ID->position index and merges via
MergeCheckpointInPlace, cloning only records adopted from the snapshot;
the persist callback still receives a deep copy. MergeCheckpoint keeps its
contract on top of the new primitive. Unchanged merge at 1000 records:
221 us / 1 MB -> ~1 us / 0 allocs; engine checkpoint of 1012 records: 507
allocs -> constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P2. Each injection regexp is gated by a substring check on literals that every
match must contain, derived from the compiled pattern via regexp/syntax.
ScanInjection verdicts, labels and order are unchanged (equivalence test over
all package test-file string literals plus mutations).
Clean text, M2 Pro: 1 KiB 572us -> 85us, 256 KiB 158ms -> 23ms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L1: classifyResourceToken stat'ed every operand on each call; the existence
answer is now kept in the per-analysis path memo (dropped with the last active
analysis). L2: two regexp.MustCompile inside functions are package-level.
RED tests count stat calls and per-call allocations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P16. RedactSecrets ran ReplaceAllString once per pattern, copying the whole
input each time even without a match. Each pass is now gated by
FindStringIndex; output is byte-identical (equivalence test over the package
test-literal corpus plus mutations). 1 MiB clean input: 65.5 MB -> 0.35 MB
allocated (wall time ~350 ms unchanged; regexp scanning dominates).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… scan

P3. IsStopword and the tokenize punctuation replacer were rebuilt per call;
ScoredMatcher now derives keyword and description sets once per skill instead
of tokenizing every description per user token; the local guard verdict for
skill bodies and descriptions is memoized by SHA-256 across reloads and
manager instances. A sidecar guard (scope enabled, non-local provider) is
consulted on every scan and never memoized; edited content hashes anew and is
rescanned.
MatchLazySkills at 200 skills: 179 ms / 448 MB -> 1.6 ms / 0.19 MB.
NewSkillManager at 200 skills: 294 ms -> 22 ms (warm memo).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P4. BuildConversationText and extractConversationText used string
concatenation in a loop (quadratic). 600 allocs per 300-turn conversation
before, 1 after; 400 KB input previously allocated ~327 MB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P7 (part 1). Every save re-ran RedactSecrets over every prior
PrincipalPrompt. The store now keeps a process-keyed digest of the prompts
written by the previous save; prompts below the redact boundary are skipped
only while that digest still matches, so an in-place edit, replacement or
trim redacts them all again. 21 redactions -> 1 on a 21-turn follow-up save.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P7 (part 2). saveLocked parsed the whole session file on every save
only to compare (generation, revision). The store now remembers the stamp it
wrote (inode, size, nanosecond mtime) and trusts it only while the file still
matches; any other writer, a same-size rewrite, a removal or a coarse-mtime
filesystem falls back to the full Load, so ErrConflict semantics are intact.
5 full loads -> 0 across 5 self-writes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lag window

Finding P7 (part 3). Every per-step SaveNoIndex rewrote index.json (and
copied the whole in-memory index) although only UpdatedAt/token counters
moved. A checkpoint now leaves the index alone when only those fields moved
by under 2s; any other field change and every full Save still write it.
5 index rewrites -> 0 for 5 step checkpoints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P13. List stat'ed every indexed session before applying limit. It now
stops as soon as limit live entries are collected (entries are sorted
newest-first, stale and unsafe ones still skipped). List(5) over 50 sessions:
50 stats -> 5. The serve search path still calls List(0); a precomputed
lowercase key needs a change in cmd/odek/serve_api.go (out of scope).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P18. Every recall query (plus each predicted-intent query) re-read and
re-parsed atoms.json via AtomStore.List. The parsed metadata is now cached by
the file stamp (size, ns mtime) and dropped on every write, so repeated lists
parse once per change; chunk text keeps its existing per-chunk mtime check.
5 parses -> 1 for 5 consecutive Lists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Findings L11 and L12 (bounded, simple variant). Cleanup held s.mu and the
cross-process sessions.lock across the deletion loop, and each deletion
rewrote the whole vector gob store (plus audit removal). The loop now only
removes session files and audit logs under the locks; vector entries are
dropped afterwards through the new VectorIndex.RemoveMany with a single store
write, then OnDelete cascades run as before. 8 sessions: 8 vector store
rewrites under the locks -> 1 write after release. Per-save Add still
rewrites the store (batching it needs an API change and is skipped).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…registry generation

Follow-up to P7: the memo that skips re-redacting unchanged prompts now
records redact.Generation(), which changes on every RegisterSecret or
ResetSecrets. A secret registered after a prompt was persisted makes the
next save scan every prompt again, so the memo can never hide a late
registration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P5: pruneFile rewrote the whole retained log through an unbuffered temp file on every
start. It now scans read-only and stops at the first expired record; only when
something must go does it open the temp file and stream through a bufio writer.
Oversized-line, regular-file and preview semantics are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P23: the writer did lock/rotate-check/open/write/close for every record. After
receiving one record it now drains already-queued records (non-blocking, up to
64 KiB) into one write. Order and drop accounting are unchanged; write failures
count every record in the batch. A 1000-record burst goes from 1000 writes to 1-2.
Coverage runtimelog 87.5% -> 88.7%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L3: outputRing.Write appended then resliced from the front, reallocating the
window on nearly every write once full. The window now lives in a reused backing
array that is compacted when its tail runs out: zero allocations per write in
steady state. Window, dropped and rune-boundary behaviour are verified identical
against the original implementation over random writes. Coverage bgproc 88.6% -> 89.1%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P21: FallbackTransport.tryURLs built a new http.Transport with keep-alives off on
every call, forcing a fresh TCP+TLS handshake per request. One shared direct client
(never the fallback transport itself, so the recursion guard holds) is now created
once, keeps connections alive and honours the proxy environment. 6 sequential
requests: 6 connections -> 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…try_after

P22: sendProgress ran Telegram edits synchronously on the agent loop, and a 429
retried 1+2+4+8s regardless of the server's hint. Progress edits now go through a
per-turn background worker with a latest-wins slot (throttled, coalesced), drained
right after the agent run returns and before the final answer or error reply is
sent, so no edit lands after the answer. Bot retries use parameters.retry_after
(bounded by the existing 8s max) in doJSON, doJSONContext and doUpload.
Coverage telegram 89.9% -> 90.0%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…goroutine

L4: chatIsIdle spawned a goroutine that stayed blocked on the chat mutex after the
2s timeout, pinning the slot until the running turn ended (one parked goroutine
per probe). The probe now polls TryLock within the wait and drops its waiter on
timeout, leaving nothing parked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P6: every pasted rune triggered a full line redraw (quadratic in paste size, many
small stderr writes). Redraws are deferred while a bracketed paste is active and
drawn once at the paste-end sequence; drawLine is a single write. 8 KB paste:
8192 redraws -> 1.
L5: history was rewritten unbuffered on every Enter. It is now buffered, and a
plain Add appends one line; a full rewrite happens only after a Load or when the
oldest entries are trimmed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L17: every odek.New rescanned the unchanged AGENTS.md (up to 256 KiB). Accepted
verdicts are now cached keyed by content SHA-256 plus the guard instance and
config; rejections are never cached, and any content or guard change rescans.
The cache is bounded to 64 entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P11. walkAndMatch collected and sorted every matching file in the
workspace, ignored cancellation and called filepath.Rel per file. It now
stops on ctx cancellation (SkipAll), matches on the path suffix without
allocating, and keeps a bounded window of the shortest paths. Output for
the returned entries is identical to the old collect-then-sort order.
Coverage internal/resource 89.2% -> 90.2%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L8. SessionResolver.Search read and fully json.Unmarshalled up to 10
session files per query just to check the id. The id is now read by
streaming the top-level object and stopping at the id key, so the message
history is never decoded (alloc for 5 x 900KB files: ~4.5MB+ -> <1MB).
Files that are not a JSON object with a matching string id are still
skipped; trailing garbage after the id is no longer detected here (Load
still fully decodes). Coverage internal/resource 89.2% -> 90.0%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P15. Emit ran redactEvent (regex scans over Tool/Data) while holding
e.mu, serializing every concurrent emitter. Redaction touches only the
event itself, so it now runs before the lock; the closed check and ID
stamping are unchanged. 8 concurrent large emits no longer take the
serial time. Coverage internal/events 95.4% -> 95.5%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P17. truncateRunes (mcpclient), boundField (artifact) and Renderer.truncate
converted the whole string to []rune just to cut a prefix. They now walk
to the byte offset of the n-th rune (artifact.TruncateRunes shared with
mcpclient). Output is byte-identical to the []rune round trip including
invalid UTF-8 (rewritten as U+FFFD only in a truncated prefix) and n==0 /
n>len. Truncating a 1MiB string: allocs 2 large -> 0 (1 for boundField).
Coverage artifact 90.0% -> 90.8%, render 68.4% -> 68.8%, mcpclient
94.2% -> 94.1% (wrapper replaced covered lines).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L10. Render ran oneLine over the entire server-controlled Summary/ID/
MediaType before truncating to MaxFieldRunes. oneLine is rune-for-rune,
so the field is now bounded first (boundOneLine) with identical output;
a 1MiB summary no longer allocates a 1MiB copy per render. Coverage
artifact 90.0% -> 91.0%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L9. readLoop copied every line to a string (Text), back to []byte, and
decoded it twice (response + id probe). It now trims scanner.Bytes() in
place and decodes once into a wire struct that keeps the id raw, so
missing/null/non-integer ids are skipped exactly as before. Allocation
for 40 x 60KB responses: 10.6MB -> ~payload size. Caps unchanged.
Coverage mcpclient 94.2% -> 94.6%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jkyberneees and others added 17 commits October 9, 2026 11:56
P14. writeWSJSON spawned a goroutine, channel and timer per frame under
the per-connection mutex, so a slow client stalled the whole delta stream.
Frames now go through a bounded (256) per-connection queue drained by one
writer goroutine that runs only while frames are pending. Non-delta frames
keep their synchronous contract and ordering; token_delta/thinking_delta
frames return immediately and are merged into an unsent same-type tail
delta (up to 64KiB) when the queue is full. Dead-connection semantics are
preserved: write timeout (also detected when a Send is parked longer than
the timeout) latches dead, releases queued senders and closes the conn
asynchronously; dead states stay registered while their writer is parked.
5000 deltas against a stalled client: previously blocked until the 30s
timeout, now returns in microseconds. cmd/odek coverage 71.9% -> 72.0%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L7. allow() allocated a fresh slice per call and gcLocked rescanned the
whole map on every call once it held 64 keys. Windows are now pruned in
place (steady state 0 allocs/call) and the sweep runs once per window or
when the map has doubled since the last sweep, so 5000 distinct keys cause
O(log n) sweeps instead of ~5000. Limiting semantics unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
L6. handleStatic hashed the full asset with SHA-256 on every request. The
embedded UI is immutable, so the ETag is memoised per handler and asset
variant (index.html has token/anonymous variants because of token
substitution). Each variant is hashed once; 304 handling is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P1. List-shaped tool results (diff, head_tail, tree, glob,
search_files, browser snapshots) called wrapUntrusted per element, and each
call ran the guard scan and a synchronous audit append that read the whole
audit file twice and parsed every line.

wrapUntrustedBatch wraps every element in its own nonce'd wrapper, scans the
joined content in groups that respect the guard text limit (a flagged group
is rescanned per element so only offenders carry the banner), and records one
audit ingest per result whose hash and resources cover all elements.
json_query coalesces its per-string ingests per source.

AuditStore remembers per session that the file was validated after an
append; the next append is a single O_APPEND write while inode and size
still match, and every other state (legacy JSON, symlink, directory, torn
tail, corruption, external writer) re-validates.

Before: a 100-line diff produced 200 audit appends (2 full-file reads each);
50 appends re-read the log 99 times. After: 1 ingest per result, 2 reads
total for 50 appends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P19. computeDiff allocated a dense (m+1)x(n+1) int table and
prepended every line to its run (quadratic in run length). It now sizes the
table by the lines between the common prefix and suffix, reads the
closed-form values (min(i, j)) for cells inside the shared prefix, and
appends lines then reverses each hunk once. Output is identical to the old
implementation, which is kept in the test file and compared over a random
tie-heavy corpus plus hand-picked tie cases.

Before: two 3000-line files differing in one line allocated 70 MiB.
After: under 8 MiB (about 0.1 MiB).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…an buffer

Finding P20. glob and search_files called os.Stat/Lstat inside the sort
comparator (about 4 n log n syscalls), classified every file before applying
file_glob, and allocated a 1 MiB scanner buffer per file.

Sorting now stats each file once and sorts on cached times with the same
comparator semantics (newest first, path order when a file cannot be
stat'ed). file_glob filters before classification, so an excluded file is
never classified (it no longer shows up in the skipped list either). Content
search shares one 64 KiB initial buffer across files; the scanner still grows
to the unchanged 1 MiB line cap.

Before: 200-file glob sort issued 422 stat calls; searching 300 tiny files
allocated 307 MiB. After: 200 stat calls; under 64 MiB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding L15. read_file hashed every byte of the file even when offset/limit
selected a window, although only a read from the top that covers the whole
file produces a receipt, allocated a 1 MiB scanner buffer per call, and kept
a dead second scan loop (the first loop already runs to EOF).

The digest is now computed only while the read can still be complete (offset
at most 1, window not yet exceeded, output not truncated) and left zero
otherwise; receipt.complete semantics are unchanged. The scanner starts at
64 KiB and still grows to the unchanged 1 MiB line cap.

Before: a tiny-file read allocated 1026 KiB; windowed reads hashed the whole
file. After: under 256 KiB, no hashing past the window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding P12. The keyword fallback loaded and lowercased every message of
every candidate session on each query, with no early exit.

deepSearch now stops loading when at least limit matches are guaranteed to
sort ahead of every remaining candidate (a deep score never exceeds the
number of distinct query tokens and ties go to the more recently updated
session, checked against the newest remaining candidate rather than assumed
list order), and stops scanning a session's messages once every token has
matched. Results are identical to the full scan: tests compare small-limit
output against the prefix of a full-limit run and pin that a higher-scoring
older session still wins.

Before: 40 candidates, limit 3 -> 40 full loads. After: at most 6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding L14. The shell approval path ran ClassifyScriptGateCtx three times
before dispatch (revalidation snapshot, checkApproval, promptUser), each a
full classification of the same command.

Call now classifies once and hands the unread-script targets to the approval
and prompt steps (checkApprovalGated / promptUserGated; checkApproval and
promptUser keep their signatures for other callers). The pre-dispatch
revalidation still re-classifies, so the TOCTOU recheck is unchanged.

Before: 3 gate classifications for an allowed command, 4 for a prompted one.
After: 2 (snapshot + revalidation) in both cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up to P10: the memoized body also depends on the known-secret
registry, so a secret registered mid-run stayed visible until the ledger
grew. The cache now records redact.Generation() and misses when it changes.
Also reword the pending-dropped view comment to state the actual invariant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t fresh stamps, repair any newline-less audit tail

Review follow-ups to P7 and P1. The principal-prompt memo captured
redact.Generation() after the scan and write, so a secret registered in
between was stamped as applied; it is now read before the loop. The lstat
revision stamp is not trusted while younger than 50 ms or without an inode,
since coarse kernel clocks can make a foreign same-size rewrite look
identical. The audit append memo also compares mtime, and the tail repair
adds a newline whenever the last line lacks one, so a complete record that
lost its terminator is never merged with the next append.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up to P14: a producer that found the queue full before the
writer first popped waited on a nil channel until the write timeout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erdicts only

Review follow-up to L17: the key formatted the guard config with %+v, which
prints nested pointers as addresses, and the guard instance by address, so
in-place config changes or address reuse could serve a stale acceptance. A
sidecar fallback to the local rules was also cached as a lasting acceptance.
The key is now the content SHA-256 plus the scope switch, and only the local
rule scanner (or no guard) is cacheable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…inish wait

Review follow-up to P22: retry_after was decoded as int, so a fractional
value failed the whole response parse and a huge value overflowed the
Duration; it is now parsed as a number, clamped to the 8 s cap. finish()
waits at most 3 s for the worker's last cosmetic edit so a rate-limited
bubble cannot hold back the answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up to P6: a 201~ split across two reads left paste mode stuck,
and with redraws now deferred during a paste typing became invisible.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…file_glob filtering

Review follow-up to P1 and P20: a model-backed sidecar judges one window at
a time, so joining elements could dilute a single injected line; only the
linear local rule scanner batches. The scan call is a hook so tests observe
what each scan covers. Files excluded by file_glob are filtered before
classification and no longer appear in skipped; noted in the cheatsheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
odek d119642 Commit Preview URL

Branch Preview URL
Oct 09 2026, 10:49 AM

Comment thread internal/bgproc/bgproc.go Fixed
jkyberneees and others added 2 commits October 9, 2026 12:05
CodeQL flagged the backing-array size computation (2 * limit) as a possible
overflow since limit is caller-controlled. Growth is now need plus one window
of headroom with saturating sums, and a write that cannot be represented keeps
only the tail that the bounded window could ever retain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eed test relative

CodeQL still flagged the ring's need+headroom sum because the operands were
only bounded relative to each other. The configured window and any single
write are now clamped to a visible constant (1 GiB), which makes every size
computation provably safe. The 256 KiB clean-scan test compared against an
absolute 100 ms, which a -race CI runner cannot meet; it now requires the
prefiltered scan to be at least 3x faster than running every pattern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread internal/bgproc/bgproc.go Fixed
jkyberneees and others added 2 commits October 9, 2026 12:21
…rd type; retry fractional retry_after everywhere; no stray progress posts

Full-branch review follow-ups. guard.IsLocal replaces provider-string
checks in the tool-output batching, the AGENTS.md scan cache and the skills
verdict memo: ScanContent consults whatever guard is attached, so only the
built-in rule scanner, whose verdict depends on the text alone, may be
batched or remembered. doJSON and doUpload still decoded retry_after as an
int, so a fractional value failed the response parse and was not retried.
When finish gives up on a stalled progress edit, the worker no longer posts
a fallback message that would land after the answer and its cleanup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL only sees bounds in the same function. The growth path now clamps the
window, the incoming write and the headroom against maxRingBytes immediately
before summing them (no-ops given Write's own clamps), and the compaction
checks compare against free space instead of a precomputed total.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread internal/bgproc/bgproc.go Fixed
jkyberneees and others added 2 commits October 9, 2026 12:48
- Batched tool-output scan: a hit on the joined text alone no longer banners
  the whole group (parity with per-element scanning; a bilingual page used to
  get hundreds of notices). Audit ingests are recorded per 64-element chunk so
  every discovered path stays on a record while a 1000-path listing costs 16
  appends.
- WS writer: a producer's wait deadline resets whenever the writer makes
  progress, so queue depth behind a slow but reading client no longer counts
  as a stall.
- Telegram: a retry_after hint applies only to the attempt right after it; a
  network error in between falls back to the exponential schedule. finish()
  returns at once when an earlier call already gave up on the worker.
- search_files no longer re-sorts what confinedGlob already ordered; the
  un-gated promptUser helper (test-only) is removed in favour of
  promptUserGated.
- bgproc ring test: the window's capacity shrinks when its front is cut, so
  the store/window capacity equality was a wrong assertion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…canning and the mtime memo check

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jkyberneees
jkyberneees merged commit 6702fd0 into main Oct 9, 2026
10 checks passed
@jkyberneees
jkyberneees deleted the fix/perf-hunt-2026-10-09 branch October 9, 2026 11:04
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