Hive Hive
Sign in

fix: bound spec-write lock waits and harden agent runs against slow LLMs

GitHub issue · Closed

Metadata
Source
tuist/hive #84
Updated
Jun 25, 2026
Domains
Hive
Details

Two independent production reliability fixes that surfaced from the same incident triage.


1. Spec writes can’t hang on a row lock

What

Bound how long any DB connection can hold a row lock, via Hive.Repo connection parameters (config/runtime.exs):

  • TCP keepalives (tcp_keepalives_idle/interval/count) — Postgres detects a dead/severed client and closes the connection, rolling back its transaction and releasing its locks.
  • idle_in_transaction_session_timeout (60s) — Postgres rolls back a transaction abandoned mid-write so it can’t keep holding the row lock.

Why

update_spec was hanging indefinitely in production. The optimistic-lock UPDATE specs … WHERE id = $1 AND lock_version = $2 must take a row lock on the existing spec row. That row was held by a stuck/abandoned connection — a request died (or its connection was severed) mid-write, leaving the server-side transaction idle-in-transaction — and Hive had no keepalives or idle timeout configured, so nothing ever reaped it and the wait was infinite.

The symptom pattern fit exactly:

  • Reads work — they take no lock.
  • create_spec works — it inserts a new row, never contending with the locked one.
  • An invalid update fast-errors — Ecto short-circuits an invalid changeset before issuing SQL, so it never reaches the locked UPDATE.
  • A valid update_spec hangs forever — it blocks acquiring the existing row’s lock.

With keepalives + idle_in_transaction_session_timeout, an orphaned holder is reaped within ~the idle timeout, so the waiting write then proceeds and succeeds. This mirrors tuist/server, which sets the same kind of connection parameters and deliberately does not special-case lock contention — ordinary concurrent edits are already handled by the optimistic lock_version (a fast stale_revision), so there’s no bespoke error path. It also protects every write path, not just specs.

Changes

  • config/runtime.exs — add parameters: [tcp_keepalives_*, idle_in_transaction_session_timeout] to the prod Hive.Repo config.

Verification

  • Confirmed against production: the orphaned lock had already cleared, spec #69 was lockable, and a live update_spec on #69 completed instantly and bumped it to revision 2.

Note on history: an earlier commit on this branch implemented this with per-transaction SET LOCAL lock_timeout and an {:error, :locked} surfaced through the MCP tools and LiveViews. That was reverted in favor of the connection-level approach above — simpler, global, and matching the reference codebase’s convention.


2. A slow LLM can’t starve the agent HTTP pool (Sentry HIVE-2)

What

Bound each agent run and give the agent HTTP pool headroom, so a slow model degrades gracefully instead of exhausting connections.

  • lib/hive/agents/sessions.ex — cap every Condukt run at 2 minutes (was Condukt’s 5-minute default; callers can still override :timeout).
  • config/config.exs — raise ReqLLM’s ReqLLM.Finch pool from the default 8 connections (stream_pool_count 8 → 16).

Why

Every agent run (revision summaries, issue triage, domain evolution) makes its LLM calls through ReqLLM’s own ReqLLM.Finch pool, which defaults to 8 connections per host. The configured model is a large, slow one, so under load — amplified by ReqLLM’s max_retries: 3, Oban max_attempts: 3, and the 15-min summary sweeper — those 8 slots starve and new calls fail with “Finch was unable to provide a connection” (HIVE-2), cascading into agent session timeouts (HIVE-A / HIVE-4).

This is isolated from SQL: every agent worker reads, releases its DB connection, makes the LLM call, then writes — the model call holds an HTTP connection, never an Ecto/DB one. So the blast radius is agent features only, never the dashboard or spec writes.

The 2-minute cap frees an agents-queue worker quickly instead of letting a stalled run linger for 5 minutes; the larger pool absorbs retry/teardown churn. Real concurrency is bounded by the :agents queue, so the extra connections add no endpoint load.

Not in this PR (follow-ups)

  • Per-task model tiers (root cause of the slowness). Every agent shares one model (HIVE_LLM_MODEL), so the heavyweight code model runs even trivial background jobs — revision-diff summaries, issue/drop classification, domain evolution. Follow-up: split into tiers — a fast, light model for the background structured agents and a stronger model only for the user-facing Slack ConversationAgent. Sessions.run/run_operation already merges a per-call model: override, so this is mainly config plumbing (e.g. a HIVE_LLM_MODEL_FAST) plus each agent declaring its tier. This is the real cure; the two changes above just keep a slow model from taking the agent pool down with it.
  • Each LLM call also emits an IO.warn-with-stacktrace because the model isn’t in ReqLLM’s catalog (“unverified model”) — log/Sentry noise, not a failure; needs an inline model spec to silence.
  • Hive.Agents.Tools.FetchUrlContent uses the default Req.Finch pool (shared with Slack/GitHub/RSS); routing it through the agent pool would fully isolate agent HTTP.
  • A no-op spec update returns a misleading spec_id has already been taken (Ecto skips the lock_version bump on an empty changeset). Tracked separately.

Verification

  • mix test — 741 tests, 0 failures. mix format --check-formatted and mix credo clean.
Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments

No GitHub comments yet.