Repository navigation
perf: 2026-10-09 performance-hunt findings, one commit per finding - #311
Merged
Merged
Conversation
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>
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>
Deploying with
|
| 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 |
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>
…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>
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
wrapUntrustedper element → one guard scan group + one audit ingest per tool result; audit append is a singleO_APPENDwrite once validatedScanInjection(derived from the compiled patterns, verdicts identical)MatchLazySkills200 skills: 179 ms / 448 MB;NewSkillManager: 294 msstrings.Builderfor vector text; prompt-redaction memo; lstat revision stamp; lazyindex.json; lazyListstatsLoadper stepretry_afterhonoured; shared keep-alive fallback transport@-completion walk; per-connection WS write queue with delta coalescing; redaction outside the events lock; cached ETags; amortised rate-limiter gcFindStringIndexgateAtomStore.Listcache, diff LCS over the changed region only, stat-once sort in search/glob, windowedread_fileskips hashing,session_searchearly exitCoverage (package, base → branch)
Visible behaviour changes
ingestchunks of at most 64 elements (hash covers every element, every discovered path stays on a record) instead of one record per element;docs/SECURITY.mdupdated. 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 byfile_globare filtered before classification and no longer listed inskipped(docs/CHEATSHEET.md).SaveNoIndexleavesindex.jsonalone when onlyUpdatedAt/token counters moved by under 2 s; listings may trail by under 2 s mid-turn (AGENTS.md).retry_afteris honoured up to the existing 8 s cap (docs/TELEGRAM.md). The fallback transport now honours proxy env vars like the primary client.artifact.TruncateRunesis exported; a negative n returns "" instead of panicking.Skipped (design decisions, not attempted)
artifact_readre-hashes the whole artifact on every windowed read. The TOCTOU check is a safety property; only a cheaper freshness check would help.logquery.Readhas no early exit on--limit; it also reports malformed/truncated/position metadata that--followrelies on.VectorIndex.Addstill rewrites the gob store per final save; onlyCleanupis batched (RemoveMany).Review
Six per-area adversarial Sonnet reviews ran against the merged commits; everything they found is fixed in the seven
review follow-upcommits at the top of the branch:redact.Generation()(a secret registered mid-run was not re-redacted).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).spacechannel initialised at construction (producers could wait on a nil channel until the write timeout).%+vwith pointer addresses) and only for the local scanner (a sidecar fallback was cached as a lasting acceptance);retry_afterparsed as a number and clamped; progressfinishbounded; paste terminator read in full.file_globfiltering documented. A 21 MBcmd/odek/odekbinary that two commits had picked up was stripped by rebuilding the branch linearly;.gitignorenow covers it.Full-branch Opus review (whole-diff, cross-area): no security regressions; independently brute-forced the
ScanInjectionprefilter (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_afterdecoding indoJSON/doUpload, memo/batch eligibility decided by the guard type (guard.IsLocal) rather than the provider string, and no fallback progress post afterfinishgives 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), staleretry_afteracross network errors (reset per attempt), repeatedfinishwaits (early return), redundantsearch_filesre-sort, deadpromptUserhelper. 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.Growinstead of summing operands; the window and each write are clamped to a 1 GiB constant.Verification
go vet ./...clean;gofmt -lclean 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 withHOME=/home/user.ODEK_E2E=true go test ./cmd/odek/ -run 'TestE2E_|TestMCPClientE2E_'green.FuzzAnalyzeBounded(20 s),FuzzPipeIntoShell(15 s) pass after the classifier changes.🤖 Generated with Claude Code