Skip to content

Let an agent (or a provider account) stop accepting new work #6255

Description

@lai3d

No way to stop new work reaching an agent without archiving it

What I want

Temporarily stop new issues being dispatched to an agent — or to every agent backed by one provider account — while letting whatever is already running finish. Then turn it back on.

Two separate triggers, and only one of them can be automated:

Manual (the one that matters most). I have one Claude account. Sometimes I want to use its quota for something else and not have an agent spend it. Nothing about the account's state signals this — only I know I'm reserving it. There is no way to express it today.

Automatic. The account is out of quota. Right now the next issue assigned still starts a run, hits 429, and fails. The failure is already classified correctly (below), so the platform knows what happened — it just has nowhere to put that knowledge.

The natural unit for both is the provider account, i.e. the runtime, not the individual agent. agent_runtime is unique on (workspace_id, daemon_id, provider), and several agents can share one runtime. If a Claude account is off-limits, it's off-limits for all of them.

Why the existing options don't cover it

  • Archive (handler/agent.go:1965) cancels every pending and active task for the agent (CancelAgentTasksByAgent, agent.go:1994). That's the right behaviour for archiving and the wrong one here — I want in-flight work to finish. It's also per-agent, so one account shared by three agents means three archives and three restores, and the name stays reserved while archived.
  • Backlog parking works but is per-issue, not per-agent: I have to remember which issues to hold and move them out later. It doesn't stop anyone else assigning new work to the agent.
  • max_concurrent_tasks can't express it — the floor is 1 (agentconfig/concurrency.go:7).
  • Stopping the daemon doesn't stop dispatch. Tasks are still enqueued and pile up waiting for the runtime to come back, so the problem is deferred rather than prevented.
  • visibility: private only narrows who can see and assign the agent. handler/issue.go:3078-3082 notes that once a private agent is assigned, its UUID is welded onto the issue and any member who can see that issue can trigger it by commenting.
  • Neither agent nor agent_runtime has an enable/disable/paused column. I went through every ALTER TABLE agent up to migration 234; the only disabled is disabled_runtime_skills (206), which is per-skill. status on both tables is runtime-derived, not settable.

Most of the machinery already exists

The admission gate is already written. isAgentAssigneeReady (handler/issue.go:3147) refuses to enqueue when the assignee's agent has no runtime:

if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
    return false
}

assigneeFallbackAgent (handler/issue.go:3088) repeats the same check for the comment-trigger path. So "this agent is not currently dispatchable, and that is not an error" is already a state the dispatcher understands — there is just no supported way to put an agent into it.

Quota exhaustion is already classified. taskfailure has two dedicated reasons — agent_error.provider_quota_limit (402, insufficient_balance, monthly usage limit, you've hit your limit, credits, quota) and agent_error.provider_capacity_or_rate_limit (429/529, rate limit, overloaded) — at pkg/taskfailure/failure.go:127,132.

They are consumed only to decide whether a fresh session might help. freshSessionMayHelp (internal/daemon/daemon.go:5807) excludes them, and its comment says exactly what's missing:

every reason with a defined non-session remedy — wait, back off, top up, re-auth, fix the config, install the binary — is excluded

The platform knows the remedy is "wait". Nothing implements the waiting.

Notes for whoever picks this up

The gate is duplicated, and that's the real risk. shouldEnqueueAgentTask exists twice — handler/issue.go:3066 and service/issue.go:502, the latter commented "Mirrors handler.shouldEnqueueAgentTask". Adding the condition to one and not the other is a silent bypass, not a compile error.

There are many enqueue call sites. Direct assignment, @mention, thread reply, squad leader, child-done rollup, autopilot, onboarding, and squad handoff all call into TaskService.Enqueue* — roughly 20 call sites across handler/comment.go, handler/issue_child_done.go, handler/issue_trigger.go, handler/onboarding_shim.go, handler/squad.go, service/autopilot.go and service/issue.go. Gating inside the task.go enqueue funnel rather than at each caller seems much safer, but I haven't checked whether every path can tolerate a refusal there.

runtime_id nullability is inconsistent across the stack. The column is nullable and the dispatch gate tests RuntimeID.Valid, but the API returns RuntimeID string (handler/agent.go:40) and the client type is runtime_id: string (packages/core/types/agent.ts:375) — both non-nullable. A NULL therefore reaches the client as "", which no schema describes. Whatever shape the fix takes, this is worth resolving, because it's the reason "detach the runtime" isn't a usable workaround even by hand.

Open question on scope

Is the switch on the agent or on the runtime? My use case is the account, which argues for the runtime, with agents inheriting. But a per-agent switch is probably the smaller change and still useful. Your call — I'd rather flag the requirement than prescribe the shape.


Filed from the outside as a user of the desktop app; all references are read from the source at b06c48941, not from docs, and I haven't verified any of it at runtime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions