Summary
On a project with a moderate backlog, the daemon issues ~90–110 Linear API requests per minute against Linear's ceiling of 2500/hour (41.7/min) — roughly 2.5× over budget. The hourly budget is exhausted ~25 minutes in, and for the remaining ~35 minutes every tracker operation fails, including state transitions (working_state / completion_state), dependency audits, and input-required replays.
Worse, the traffic is dominated by per-issue polling loops whose volume is proportional to the number of stuck issues, and one of those loops is required to unstick them — so once the budget is exhausted the system cannot recover on its own within the window.
Observed on v0.2.0 (6d1c67c), tracker linear, max_concurrent_agents: 10, polling.interval_ms: 60000.
Evidence
GET /api/v1/state during the incident:
"rateLimits": {
"requestsLimit": 2500,
"requestsRemaining": 0,
"requestsReset": "2026-08-03T22:54:36+03:00",
"complexityLimit": 3000000,
"complexityRemaining": 2999817
}
complexityRemaining is essentially untouched — this is request count exhaustion from many tiny queries, not query weight.
Once the budget is gone, every request logs linear: graphql error response, which makes the true request rate exactly measurable:
59 T21:40 103 T21:45 111 T21:48
86 T21:41 109 T21:46 152 T21:49
81 T21:42 108 T21:47 91 T21:50
99 T21:43 74 T21:51
87 T21:44 43 T21:52
Attribution over that same 13-minute window (1,203 total requests):
| requests |
share |
source |
| 358 |
30% |
automation: input-required replay detail fetch |
| 337 |
28% |
orchestrator: dependency audit refresh |
| 185 |
15% |
orchestrator: tracker-reply check |
| 137 |
11% |
orchestrator: pending input resume detail fetch |
| ~186 |
15% |
candidate polls, worker fetches, comments, state transitions |
84% of all Linear traffic is four per-issue re-fetch loops. Actual work — moving an issue, posting a comment — is in the remaining 15%, and it is what starves.
Root causes
1. input-required replay re-fetches every entry every 15s. cmd/itervox/automations_input_required.go:135 — replayInputRequiredIssueDetail takes a cache map[string]*domain.Issue, but that cache is constructed per invocation, so it never survives a tick. With the automation watcher on a 15s ticker (cmd/itervox/automations.go:78), N input-required entries cost N × 4 requests/minute indefinitely. The backlog on this project went 5 → 19 entries during a single investigation, i.e. 20 → 76 req/min from this loop alone.
2. Dependency-audit refresh has a per-tick budget but no staleness TTL. internal/orchestrator/dependency_audit.go:250. The only skip condition is entry.LastAuditedAt.Equal(now) (line 268), so all 59 audit rows cycle through the 20-row budget (dependencyAuditRefreshPerTickBudget, line 25) forever, whether or not anything changed. Steady-state cost with nothing happening in the project.
3. checkTrackerReplies is uncapped. internal/orchestrator/event_loop.go:553 iterates every InputRequiredIssues entry and calls FetchIssueDetail per entry, with no per-tick budget like the dependency audit has. Each result additionally calls auditFetchedIssueDependenciesAndDispatch.
4. UpdateIssueState costs 2 requests and caches nothing. internal/tracker/linear/client.go:255. Step 1 resolves state name → UUID via issue → team → states; step 2 mutates. The team's workflow-state map is static, but it is re-resolved on every single transition. The completion transition retries up to 4× (internal/orchestrator/worker.go:930), so a failing move to completion_state burns up to 8 requests and still fails.
The feedback loop (why it can't recover)
internal/orchestrator/event_loop.go:628 — resuming a pending_input_resume entry requires a successful FetchIssueDetail:
detailed, err := o.tracker.FetchIssueDetail(ctx, entry.IssueID)
if err != nil {
slog.Warn("orchestrator: pending input resume detail fetch failed", ...)
continue
}
So:
- Issues accumulate in
input-required / pending_input_resume.
- Loops 1–3 re-poll every one of them on every tick → budget exhausted.
- The resume path needs one more Linear read → it 429s →
continue.
- The entry stays queued, so it keeps being polled → budget stays at zero.
Observed directly: input-responder entries sat in the automation queue with reason: pending_input_resume for 75+ minutes while automationQueue never drained. The backlog that causes the exhaustion is the same backlog that the exhaustion prevents draining.
Secondary effect: successful work gets parked
internal/orchestrator/worker.go:963 — when the completion_state transition fails all 4 attempts, the issue is added to userCancelledIDs, which the exit handler converts into a pause. So under sustained rate limiting, an issue whose agent run succeeded can end up paused with its work done and the tracker never updated. This had not yet fired on our deployment (0 occurrences) but the budget sat at 0 for 35 minutes of every hour, so it is reachable.
Related: issues paused this way are indistinguishable from human-cancelled ones, because PausedIdentifiers is map[identifier]issueID and records no reason.
Suggested solutions
Ordered by impact-to-effort.
A. Cache the Linear workflow-state map (internal/tracker/linear/client.go:255). Team-scoped name → UUID, effectively static; a process-lifetime map keyed by team ID with a long TTL halves the cost of every transition and removes the fetch state id failure mode entirely. Smallest patch, clearest win.
B. Persist the input-required detail cache across ticks (cmd/itervox/automations_input_required.go:135). Hoist the cache to the watcher with a short TTL (30–60s), or skip re-fetching an entry whose QuestionCommentID and QueuedAt are unchanged since the last successful fetch. Removes the largest single consumer.
C. Add a staleness TTL to the dependency-audit refresh (internal/orchestrator/dependency_audit.go:268). Skip rows whose LastAuditedAt is newer than some interval (e.g. 10 min) instead of only those audited at exactly now. Keeps the existing priority ordering and per-tick budget, but makes an idle project cost ~0 instead of 20 requests/tick.
D. Cap checkTrackerReplies with a per-tick budget (internal/orchestrator/event_loop.go:553), mirroring dependencyAuditRefreshPerTickBudget, with oldest-first ordering so no entry starves.
E. Global tracker rate limiter with write priority. Track requestsRemaining (already parsed into rateLimits) and, below a reserve threshold, shed polling reads while still admitting writes — state transitions, comments, and the FetchIssueDetail on the resume path. This is what breaks the feedback loop: reads should never be able to starve the operations that let the queue drain. Backing off polls proportionally to remaining budget would also stop the daemon from spending its whole window in the first 25 minutes.
F. Record a pause reason in PausedIdentifiers (currently map[identifier]issueID). Distinguishing rate_limited / retries_exhausted / user_cancelled / transition_failed would let a transition-failure pause be retried automatically instead of needing a human, and would make the worker.go:963 behaviour recoverable.
Happy to send a PR for A–D if the approach looks right.
Summary
On a project with a moderate backlog, the daemon issues ~90–110 Linear API requests per minute against Linear's ceiling of 2500/hour (41.7/min) — roughly 2.5× over budget. The hourly budget is exhausted ~25 minutes in, and for the remaining ~35 minutes every tracker operation fails, including state transitions (
working_state/completion_state), dependency audits, and input-required replays.Worse, the traffic is dominated by per-issue polling loops whose volume is proportional to the number of stuck issues, and one of those loops is required to unstick them — so once the budget is exhausted the system cannot recover on its own within the window.
Observed on
v0.2.0(6d1c67c), trackerlinear,max_concurrent_agents: 10,polling.interval_ms: 60000.Evidence
GET /api/v1/stateduring the incident:complexityRemainingis essentially untouched — this is request count exhaustion from many tiny queries, not query weight.Once the budget is gone, every request logs
linear: graphql error response, which makes the true request rate exactly measurable:Attribution over that same 13-minute window (1,203 total requests):
automation: input-required replay detail fetchorchestrator: dependency audit refreshorchestrator: tracker-reply checkorchestrator: pending input resume detail fetch84% of all Linear traffic is four per-issue re-fetch loops. Actual work — moving an issue, posting a comment — is in the remaining 15%, and it is what starves.
Root causes
1.
input-requiredreplay re-fetches every entry every 15s.cmd/itervox/automations_input_required.go:135—replayInputRequiredIssueDetailtakes acache map[string]*domain.Issue, but that cache is constructed per invocation, so it never survives a tick. With the automation watcher on a 15s ticker (cmd/itervox/automations.go:78), N input-required entries costN × 4requests/minute indefinitely. The backlog on this project went 5 → 19 entries during a single investigation, i.e. 20 → 76 req/min from this loop alone.2. Dependency-audit refresh has a per-tick budget but no staleness TTL.
internal/orchestrator/dependency_audit.go:250. The only skip condition isentry.LastAuditedAt.Equal(now)(line 268), so all 59 audit rows cycle through the 20-row budget (dependencyAuditRefreshPerTickBudget, line 25) forever, whether or not anything changed. Steady-state cost with nothing happening in the project.3.
checkTrackerRepliesis uncapped.internal/orchestrator/event_loop.go:553iterates everyInputRequiredIssuesentry and callsFetchIssueDetailper entry, with no per-tick budget like the dependency audit has. Each result additionally callsauditFetchedIssueDependenciesAndDispatch.4.
UpdateIssueStatecosts 2 requests and caches nothing.internal/tracker/linear/client.go:255. Step 1 resolves state name → UUID viaissue → team → states; step 2 mutates. The team's workflow-state map is static, but it is re-resolved on every single transition. The completion transition retries up to 4× (internal/orchestrator/worker.go:930), so a failing move tocompletion_stateburns up to 8 requests and still fails.The feedback loop (why it can't recover)
internal/orchestrator/event_loop.go:628— resuming apending_input_resumeentry requires a successfulFetchIssueDetail:So:
input-required/pending_input_resume.continue.Observed directly:
input-responderentries sat in the automation queue withreason: pending_input_resumefor 75+ minutes whileautomationQueuenever drained. The backlog that causes the exhaustion is the same backlog that the exhaustion prevents draining.Secondary effect: successful work gets parked
internal/orchestrator/worker.go:963— when thecompletion_statetransition fails all 4 attempts, the issue is added touserCancelledIDs, which the exit handler converts into a pause. So under sustained rate limiting, an issue whose agent run succeeded can end up paused with its work done and the tracker never updated. This had not yet fired on our deployment (0 occurrences) but the budget sat at 0 for 35 minutes of every hour, so it is reachable.Related: issues paused this way are indistinguishable from human-cancelled ones, because
PausedIdentifiersismap[identifier]issueIDand records no reason.Suggested solutions
Ordered by impact-to-effort.
A. Cache the Linear workflow-state map (
internal/tracker/linear/client.go:255). Team-scopedname → UUID, effectively static; a process-lifetime map keyed by team ID with a long TTL halves the cost of every transition and removes thefetch state idfailure mode entirely. Smallest patch, clearest win.B. Persist the input-required detail cache across ticks (
cmd/itervox/automations_input_required.go:135). Hoist the cache to the watcher with a short TTL (30–60s), or skip re-fetching an entry whoseQuestionCommentIDandQueuedAtare unchanged since the last successful fetch. Removes the largest single consumer.C. Add a staleness TTL to the dependency-audit refresh (
internal/orchestrator/dependency_audit.go:268). Skip rows whoseLastAuditedAtis newer than some interval (e.g. 10 min) instead of only those audited at exactlynow. Keeps the existing priority ordering and per-tick budget, but makes an idle project cost ~0 instead of 20 requests/tick.D. Cap
checkTrackerReplieswith a per-tick budget (internal/orchestrator/event_loop.go:553), mirroringdependencyAuditRefreshPerTickBudget, with oldest-first ordering so no entry starves.E. Global tracker rate limiter with write priority. Track
requestsRemaining(already parsed intorateLimits) and, below a reserve threshold, shed polling reads while still admitting writes — state transitions, comments, and theFetchIssueDetailon the resume path. This is what breaks the feedback loop: reads should never be able to starve the operations that let the queue drain. Backing off polls proportionally to remaining budget would also stop the daemon from spending its whole window in the first 25 minutes.F. Record a pause reason in
PausedIdentifiers(currentlymap[identifier]issueID). Distinguishingrate_limited/retries_exhausted/user_cancelled/transition_failedwould let a transition-failure pause be retried automatically instead of needing a human, and would make theworker.go:963behaviour recoverable.Happy to send a PR for A–D if the approach looks right.