Skip to content

fix(heartbeat): make the company WIP limit a reservation, not a count - #5

Merged
elysosss merged 2 commits into
fork/mainfrom
fix/company-wip-reservation
Aug 11, 2026
Merged

elysosss merged 2 commits into
fork/mainfrom
fix/company-wip-reservation

Conversation

@elysosss

Copy link
Copy Markdown
Owner

The bug

The company-wide WIP limit was enforced by counting running runs in countRunningRunsForCompany, called from startNextQueuedRunForAgent roughly eleven database round trips before the conditional UPDATE in claimQueuedRun that actually flips a run to running. The only mutex in that window, withAgentStartLock, is in-process and keyed by agent id — two agents belonging to the same company take two different locks and both sail straight through. Both read zero running runs, both claim, and the company spends two runs where the operator configured a limit of one. It fails on money, not just correctness.

Pushing the count into the UPDATE's WHERE clause does not fix it either. Under READ COMMITTED, each implicit transaction takes its snapshot before the other commits, so both subqueries still see zero running runs regardless of where the count is evaluated. The serialization point has to be a lock, not a smarter query.

The fix

withCompanyWipSlot (new, server/src/services/company-wip-limit.ts) takes a per-company pg_advisory_xact_lock(hashtextextended(key, 0)), re-counts running runs under that lock, and only then runs the caller's claim UPDATE inside the same transaction. It lives in the database, so it holds across server processes, and Postgres releases it automatically when the transaction ends — including when a process dies, which a counter table would not do. Disabled by default (limit <= 0), so upstream behaviour is unchanged when the limit isn't configured.

heartbeat.ts's claimQueuedRun now runs its claim UPDATE through withCompanyWipSlot. Since a losing claim now returns null (run stays queued) instead of racing to a duplicate running row, the loser needs to be redispatched once the winner's slot frees up. startNextQueuedRunsForCompanyPeers sweeps the company's other agents with queued runs when a run completes; without it the freed slot would sit idle until the next resumeQueuedRuns timer tick, turning the over-spend bug into a latency bug instead.

Tests

server/src/__tests__/heartbeat-company-wip-limit-race.test.ts (new, 3 tests, embedded Postgres):

  • holds a second claimant outside the company slot until the first claim commits
  • starts exactly one run when two agents of one company wake at the same instant
  • re-dispatches the losing agent company-wide when the winning run finishes

Reverted to the pre-fix code (git stash on both changed files, keeping the test file) and re-ran the suite: all 3 failed —

  • withCompanyWipSlot is not a function (function doesn't exist yet)
  • expected false to be true (both agents ended up running)
  • expected null not to be null (loser never got re-queued)

Restored the fix and re-ran: all 3 green again. The test is not a tautology — it fails without the fix and passes with it.

Verified

Check Result
pnpm --filter @paperclipai/server typecheck clean
target suite (heartbeat-company-wip-limit-race.test.ts) 3 passed
target suite against pre-fix code 3 failed (see above)
target suite after restoring the fix 3 passed
company-wip-limit.test.ts (existing unit tests) 8 passed
neighbouring heartbeat suites (issue-liveness-escalation, start-lock, stale-queue-invalidation, retry-scheduling, dependency-scheduling, lock-release-on-reassignment, run-lease-release-terminalization) 91 passed, no regressions

Risks

Low. The limit is opt-in (disabled unless companyMaxConcurrentRuns > 0), so this only changes behavior for operators who already configured a company-wide cap — and for them it closes an over-spend hole rather than opening one. The advisory-lock transaction body is deliberately three statements (lock, re-count, delegate to caller's UPDATE) with no budget checks or adapter calls inside it, to avoid serializing slow work across the whole company or deadlocking against the longer-lived paperclip:folders:* locks.

The company-wide limit was enforced by counting running runs before
claimQueuedRun, roughly eleven round trips before the conditional UPDATE
that actually flips a run to running. The only mutex in that window,
withAgentStartLock, is in-process and keyed by agent id, so two agents of
one company take two different locks and both sail through: both read
zero running runs, both claim, and the company spends two runs where the
operator configured one.

Pushing the count into the UPDATE's WHERE clause does not fix it either.
Under READ COMMITTED both implicit transactions take their snapshot
before the other commits, so both subqueries still see zero. The
serialization point has to be a lock, not a query.

withCompanyWipSlot takes a per-company pg_advisory_xact_lock, re-counts
under it, and only then runs the caller's claim UPDATE inside the same
transaction. It lives in the database so it holds across server
processes and Postgres drops it automatically when the transaction ends,
including when a process dies. Disabled by default (limit <= 0), so
upstream behaviour is unchanged when the limit isn't configured.

Wrapping the claim in a transaction means the losing agent's run stays
queued instead of erroring, so it needs to be redispatched when the slot
frees up. startNextQueuedRunsForCompanyPeers sweeps the company's other
agents on run completion; without it the freed slot would sit idle until
the next resumeQueuedRuns timer tick, turning the over-spend bug into a
latency bug instead.
@elysosss

Copy link
Copy Markdown
Owner Author

Reviewed before merging. The reservation itself is right, and the two things I checked most carefully hold up:

  • The transaction body is three statements — lock, re-count, the caller's UPDATE. Nothing slow runs under a company-wide lock, so this cannot serialize a company or deadlock against the longer-lived paperclip:folders:* locks.
  • The .set({…})/.where(…) lines are byte-identical to upstream's, only re-indented into the callback. The next upstream sync will see a formatting change here, not a semantic one.

One gap worth recording rather than fixing here. startNextQueuedRunsForCompanyPeers is wired into one completion path (:16431). reapOrphanedRuns (:13305) also frees a company slot and re-dispatches only the reaped agent, so a queued peer there waits for the next resumeQueuedRuns tick instead of starting immediately.

That is not a regression: before this change a run blocked by the company limit stayed queued and waited for exactly that same timer, on every path. The sweep is a latency improvement on the hot path, and the reap path keeps the old behaviour. Reaping is itself timer-driven, so the extra wait is bounded by the same interval that triggered the reap. Worth a follow-up only if the reap path turns out to be common — which would mean something worse is wrong anyway.

The completion path was the only place the freed company slot was handed
to a waiting peer. Two other paths free the same slot: reapOrphanedRuns,
when a run's process died and the run is finalized as failed, and
cancelRun, when an operator stops a running run. Both re-dispatched only
the agent whose run ended, and the agent waiting on the company slot is
by definition a different one, so the slot sat idle until the next
resumeQueuedRuns tick.

Self-healing on a timer, so not a correctness bug — but it is exactly the
latency the peer sweep exists to avoid, and leaving two of the three
paths uncovered would have made the sweep look complete when it wasn't.
@elysosss

Copy link
Copy Markdown
Owner Author

Follow-up commit 272ca4dc8 after review.

The peer sweep was wired into one slot-freeing path — the normal completion path at the end of a run execution. Two others free the same company slot and were left uncovered:

  • reapOrphanedRuns: a run whose process died is finalized as failed, which frees the slot, but only startNextQueuedRunForAgent(run.agentId) was called — and the agent waiting on the slot is by definition a different one.
  • cancelRun: an operator stops a running run. Same shape.

In both cases the freed slot sat idle until the next resumeQueuedRuns tick. Self-healing, so not a correctness bug — but it is precisely the latency the sweep was added to prevent, and covering one of three paths would have made the sweep read as complete when it wasn't.

Re-validated after the change: @paperclipai/server typecheck exit 0, and the race suite 3/3 on a real embedded-Postgres run.

@elysosss
elysosss merged commit 593a86f into fork/main Aug 11, 2026
22 checks passed
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.

1 participant