# trelix v2.7 to v2.9: The Release Where the Pipeline Itself Became the Product

> Source: <https://dev.to/sai_ram_0000/trelix-v27-to-v29-the-release-where-the-pipeline-itself-became-the-product-21kb>
> Published: 2026-07-24 16:44:32+00:00

On 2026-07-09 I shipped trelix v2.7.0. The architecture felt done — seven retrieval legs, a knowledge graph, an agentic loop. Then I opened the GitHub Release page and counted two binary assets where there should have been three. `release.yml`

built the macOS and Linux PyInstaller binaries both as `dist/trelix`

, and `softprops/action-gh-release`

uploads assets by basename — two files with the same name collide into one asset, with no metadata telling you which OS survived. I genuinely could not tell, from the published release, whether the surviving binary was macOS or Linux. That's not a bug in trelix's retrieval logic; it's a bug in the thing that puts trelix in front of users, and it shipped because nobody checked the page after the workflow went green.

That bug is why this article exists. v2.7.1 through v2.9.0 — six releases over two weeks — spend a surprising amount of energy on parts of the project that aren't retrieval quality at all: the release pipeline, the concurrency model, a language-parser migration, the deployment story, and a VS Code extension and GitHub App with real, embarrassing problems. I'm grouping by theme instead of walking the changelog top to bottom.

The binary collision got fixed in v2.7.1 (2026-07-10) by renaming each binary uniquely before upload. Fresh eyes on the pipeline turned up company: the PR-time CI workflow had never built a Linux binary, even though the release workflow built one at tag time. I added the missing matrix entry and a verify step.

The most embarrassing find: `trelix-mcp`

's own test suite had never run in CI. It let a real regression sit undetected — a test asserted the MCP server had "exactly 6 tools" when it had actually registered 8 since two subscription tools shipped in v2.5.0. A wrong-but-passing test is worse than no test. I wired the tests into CI and fixed the assertion.

Smaller mistakes, same release: three companion packages' dependency floors on trelix core had been bumped in v2.7.0 on the assumption they used new APIs. None did, so I reverted them. The changelog had also rotted: an entry was defined twice with conflicting URLs, silently resolving to the last definition, so the first link was dead. Rebuilt the footer from the actual git tags.

v2.7.2 (2026-07-12) reads like the payoff of finally taking concurrency seriously instead of assuming `check_same_thread=False`

meant "safe." It shipped real scale features — Qdrant Cloud readiness, incremental per-symbol embedding on partial re-index, an opt-in parallel BM25 read pool — but the five bugs stress-testing surfaced matter more.

A TOCTOU race in the sparse embedder's lazy-load checked whether the model was loaded before acquiring its lock, so two racing threads could both see "not loaded" and both start loading; fixed with double-checked locking. An MCP stdout write race let concurrent notification writes interleave partial JSON-RPC lines, corrupting a client's output; fixed with a lock around the write-and-flush pair. And unbounded subscription-registry growth let a misbehaving client grow the registry forever, fixed with a max-subscriber cap and a TTL sweep.

The one I find genuinely unsettling in hindsight: silent foreign-key corruption on partial re-index. The parent-symbol, call-callee, and type-edge columns are all set to null on delete, which sounds safe until deleting a changed symbol's old row silently nulls those links on every row that referenced it — including unchanged rows with nothing to do with the edit. No error, just quietly severed graph edges accumulating as files change. I added snapshot-and-repoint helpers so the indexer captures stale links before the delete and repoints them.

And the BM25 lock was incomplete even after I thought I'd handled it. The shared database connection is opened with `check_same_thread=False`

, which I'd treated as a green light for concurrent use — it isn't; that flag disables SQLite's thread-affinity check, not concurrent-statement safety. Grep, sparse, and vector legs all hydrate through that connection from sibling worker threads. I added a real lock everywhere it's touched from a worker thread, verified with a 60-thread by 10-iteration by 3-leg stress test and zero errors — the only way I'd trust this, given the previous fix had looked equally trustworthy.

Same release: a Qdrant client API migration to keep pace with an upstream deprecation, pinned so a future major bump can't break it again. One honest non-win: Windows ARM64 binaries were briefly added to the build matrices, then reverted — two core dependencies publish no wheel for that target. Linux ARM64 shipped; Windows ARM64 didn't.

v2.7.3 was a pure documentation release. A full README audit fixed 15+ factual bugs: wrong env var names, fabricated pip extras, a broken Homebrew tap, a config value that crashes on use, wrong REST method and table names. The architecture diagram got redrawn to show all 7 retrieval legs instead of 3. I backfilled the changelog's empty `[2.2.0]`

entry, which had shipped 5 real features — agentic ReAct loop, data-flow analysis, taint analysis, sparse+dense hybrid retrieval, multi-granularity indexing — never documented anywhere. The README shrank by roughly a third by consolidating duplicated API sections into pointers. None of it changed behavior; it changed whether docs matched reality.

v2.9.0 is where trelix stopped being "clone it, pip install, run the CLI" and became something you could put in front of other services. Four pieces went in, and the order was the point.

Typed REST API response models came first — every route now declares a real Pydantic model instead of an untyped object in the OpenAPI schema, groundwork for the next item. Cursor pagination on the search endpoint came second, the one deliberately narrow breaking change in an otherwise additive release: the endpoint now returns a results-plus-cursor envelope instead of a bare list, matching the MCP tool's existing pagination contract, done so the new SDK wouldn't lock in against the old shape.

The TypeScript SDK came third — a hand-written HTTP client with generated types covering every route, with `/ask`

's streaming response getting its own async generator because token-plus-terminator-plus-error-frame semantics don't fit the same shape as everything else. Fourth, OpenTelemetry tracing — opt-in, off by default, emitting one span per retrieval leg plus spans for each pipeline stage. Worth explaining: OTel's context propagation doesn't cross a thread-pool boundary automatically, and trelix's parallel sub-query execution runs inside exactly that kind of pool, so naive instrumentation produces disconnected, orphaned spans. The fix wraps the traced function to carry the current context across the pool boundary — the docs note the underlying span-naming conventions are still "Development," not "Stable."

The deployment story rounds out with an official multi-arch Docker image in two variants — a slim, API-embedder-only build and a `-local`

build bundling the offline embedding stack — running as a non-root user, with the entrypoint overriding the CLI's loopback-only default to listen on all interfaces (a loopback bind is a silent dead-end in a container unless overridden). Plus a Helm chart modeling the server's real behavior: every route re-derives its config from the request's own repo parameter, so one Deployment is already multi-repo-capable — but the chart's persistent volume is a shared data directory across every repo you serve through it, called out loudly in the docs. Ingress defaults to disabled: the server ships with zero auth middleware, and I'd rather state that directly than bury it.

Building this surfaced the same documentation-rot pattern again: docs referenced an embedder env var that was actually a silent no-op, plus a nonexistent CLI flag and Docker examples with the wrong port — docs drift silently unless something forces someone to run the commands in them.

The Python 3.13 item sounds like the least interesting line in the whole changelog: bump the minimum version, done. The actual blocker was `tree-sitter-languages`

, abandoned upstream with no wheels for the new interpreter. The fix was a single swap to the actively-maintained `tree-sitter-language-pack`

, behind trelix's one chokepoint for grammar loading — that's the whole diff at the chokepoint. It is not the whole blast radius: the new library exposes different AST node names and shapes, which propagated silently into nearly every per-language extractor, because each had been written against the old grammar's node vocabulary without anyone realizing how much was implicit knowledge rather than a documented contract.

Six separate bugs came out of this: C#'s grammar name changed, silently tolerated before this release only because the old library accepted both names; Kotlin's extractor had to be rewritten entirely after its field-lookup API stopped returning anything, breaking class, interface, enum, function, and property extraction at once; Python's docstring extraction broke because the new grammar drops a wrapper node; Go's interface-method node was renamed; TypeScript's interface-body node got a new name; and C#'s `using`

alias imports lost their wrapper node.

Every one of these was catchable only because per-language extractor tests already existed before the migration — without them, most would have shipped as silent failures. A seventh casualty I almost missed: the PyInstaller build spec still imported the retired package, breaking every binary build with a flat import error, because that build runs in its own workflow, outside the test suite and linters. Dropped the stale entries, added the new package as a hidden import, verified with a local build plus a smoke test.

One behavior change worth knowing: grammar loading is now network-on-first-use with local caching, not bundled in the wheel — fine on a laptop, mildly alarming for an air-gapped job. A prefetch function warms the cache during image builds; CI runs it automatically now.

The search command became a debounced (250ms) search-as-you-type picker with live snippet preview, instead of the old one-shot input-then-static-list flow. The preview runs through a virtual document provider that gets real syntax highlighting for free by keeping the real file's extension on its virtual URI. The state machine lives in its own testable class, verified with a fake-timer harness proving the debounce actually debounces.

The security fix here is direct: the ask-panel's Webview interpolated the raw, unescaped LLM answer string straight into HTML, with no content-security policy and no script restrictions. A crafted or adversarial answer — not a hypothetical when you're piping retrieval results through a model — could execute arbitrary script inside the Webview's context. Fixed with HTML-escaping plus disabled scripts and an explicit deny-all CSP; it's an XSS vulnerability, and it's fixed now. Separately: search results had been silently mis-parsed the entire time the extension existed — it read the wrong field names off each result, confirmed against the actual server source, so those fields were always empty strings and clicking a result opened a broken, empty file URI.

And then the line I'm quoting close to verbatim because paraphrasing softens it: the trelix Code Review Check has never posted a single real annotation since the workflow shipped. Status messages ran unconditionally to stdout even in JSON output mode, and combined with the workflow's output redirect, the annotation-posting step's JSON parse had been throwing on every run, silently swallowed since the day this workflow shipped. Every PR ever reviewed by this Check got nothing. Fixed by routing status output to stderr and narrowing the redirect to stdout only. Even after that fix, the mapping logic still wouldn't have worked — wrong response keys, lowercase severity strings compared against real uppercase values. New regression tests were verified against the pre-fix code first — most failed with the exact parse error this bug produces — before trusting they passed for the right reason. The GitHub App also reached GA-readiness: real installation-token minting behind an expiry-aware cache, webhook signature verification via constant-time comparison, a request-body size cap matching GitHub's own limit, and a subprocess timeout on the review shell-out.

v2.8.0 and v2.8.1 shipped the same day, 2026-07-20, and the second was a direct response to auditing the first before letting it sit. v2.8.0 exposed the existing federation infrastructure to MCP clients through four new tools, and added a CLI command apparently missing despite the underlying method already existing. Persistent agent memory landed for the agentic loop too — a follow-up call can now resume a prior conversation with full context, sessions auto-evicting after a week of inactivity.

Building this surfaced two real, previously invisible bugs: federated search had silently lost repo provenance in an earlier refactor, so the search command's repo column had been blank with no test catching it; and a per-repo weighting setting — settable, stored, documented — had never actually been forwarded into the fusion math, silently doing nothing since it was added.

v2.8.1 is where the real security finding lives. All four federation MCP tools passed a caller-supplied config path straight into the registry's load/save calls with zero validation, meaning an MCP client — including a prompt-injected agent, exactly the threat model MCP has to take seriously — could point registry I/O at an arbitrary filesystem path. I found this in a pre-push audit of v2.8.0, before it reached anyone running the released version. The fix confines the path to one of two known-safe directories via a proper containment check, not a naive string-prefix check, which would also incorrectly match a similarly-named sibling directory. Same release: repo-count and fan-out caps so a runaway add-repo loop can't scale every search linearly against an unbounded repo count, plus a pagination fix for a per-repo candidate pool that had been widening as the cursor grew, letting later pages get fused from a differently-shaped pool than earlier ones.
