Inhibitors¶
How work is stopped or held, by whom, and what it takes to start again.
What exists¶
The storage and the join. inhibitors rows in the database, an
InhibitorStore that takes, releases and resolves the cascade, and
inhibitor::decide -- a plain function over a slice that returns the verdict
and everything that contributed to it.
Two checkpoints consult it. Turn preparation decides whether a runtime is handed
work at all: a stopped verdict refuses the turn and latches the session with
what stopped it, the job completes rather than failing, and the runtime is told
only that there is no work -- it is not the tier that decides, so it is not told
why. The gateway decides whether a turn already running may keep going.
Both latch, and both go through decide to say why. A stop reported by one and
a stop reported by the other name the same holds in the same words: two holds at
once otherwise have the gateway naming one and preparation naming both, and
whoever reads the stopped session releases the hold they were shown and finds it
still stopped.
The latch is agent_sessions.stopped_at and stopped_reason, cleared by a
prompt carrying a real user_id whose turn then goes on to run. Both halves
matter. A person nudging a conversation that is still held does not lift the
latch: it is read, the holds are evaluated, and only a Proceed clears it.
Clearing it first and re-taking it when the verdict comes back stopped would
work -- stop_session refuses to write where stopped_at is already set -- but
only by accident of ordering, and it did not: the clear made the column null, so
the re-take stamped now() with the current reason. A conversation held for
three weeks that somebody nudged daily reported being stopped a minute ago, and
Stopped.at exists precisely to say otherwise.
Two authorities decide who may hold what. agents:inhibit stops one agent and
is held by operators as well as admins -- whoever builds agents is the right
person to stop one misbehaving. workspaces:inhibit stops everything and is
admins only, because it halts work its holder may know nothing about. Neither is
folded into the matching :update: a credential that exists to halt an org
should not also be able to rename or delete it.
Seeing what is held needs only agents:read. Withholding the reason from
somebody watching a silent agent is how "why is nothing happening" becomes a
support ticket.
POST /v1/workspace/stop and POST /v1/agents/{id}/stop take a hold, both
requiring a reason. DELETE /v1/inhibitors/{id} lifts one by the handle taking
it returned -- by id rather than by scope, because several holds can cover the
same work and releasing "the workspace's" would be ambiguous about which. The
UI lists them on the agents page, workspace-wide holds first.
A restarted conversation carries a marker saying why it stopped, placed ahead of what it explains. Both shapes exist: a message that went unanswered, and a reply that stops partway.
A hold also cuts a turn already running: the gateway polls for it on the same tick it polls for cancels, and the reason rides the trailer so the runtime can say what stopped it rather than looking like a provider that hung up.
Suspension is taken now, by api::gated: a request a skill declared as needing
approval is refused by the gateway, and the API takes a Strength::Suspended
hold on the conversation while somebody is asked. The two stop endpoints still
write Strength::Stopped, which is right -- an operator stopping a workspace is
not asking a question.
That last part was the gap and is now closed. A suspension pauses where the
conversation is consistent and resumes of its own accord -- which is why it
needs no marker, there being no fragment to explain. The turn moves to parked,
a job state of its own, and releasing the hold gives it back.
The serial key was the hazard worth naming, and it turned out to need no change:
the claim excludes keys that are running, so a parked turn never held its
session's queue closed. There is a test that fails if that exclusion ever widens
to cover parked, because a suspension that froze the whole conversation would be
worse than the decline it replaced.
A hold taken while a turn is already streaming is the case prepare_turn cannot
see, because that turn was claimed before the hold existed. The guest hears about
it at its next round boundary -- never mid-round, since a round cut partway has
its tool calls refused wholesale -- finishes what it is holding, and returns what
it has. The job then parks rather than completing, keeping the reply it wrote,
so answering the approval has a turn to give back. Completing was the bug: the
hold was taken, the job succeeded, and the yes resumed nothing.
One thing such a turn must not do is latch its session. Latching is what makes a
stop need a person to lift it, and an approval is lifted by a person -- so
latching would leave the conversation stopped after the yes, with the hold
released and the turn resumed and the session refusing to run it. The two are
told apart by awaiting_approval on the turn's result rather than by reading the
reason.
A stop that lands over a pending approval does not latch. The two holds stack the ordinary way and the stop wins, so the turn is refused -- but a latch is the wrong thing to leave behind on a conversation that was already waiting rather than stopped. A latch outlives the hold that set it, which is its whole purpose; here that means the approver answers, the suspension is released, the turn is given back, and it refuses again on a latch nobody can see, with the approval already gone from the queue. They did exactly what they were asked and the conversation stayed dead.
So should_latch asks whether any contributor is a suspension. Two stops are
still a stop. The stop refuses the turn on its own and lifts of its own accord,
which is also why such a turn is announced as resumable: telling its reader to
send a message would have them typing into a conversation that is already coming
back, and clearing a latch that is not there.
It is a free function rather than a branch inside worker::inhibited because
that branch is unreachable from a test without a live worker -- inverted, every
test in the crate still passed.
Who may answer an approval, and what raises one, is in action-queue.md; what a yes is then worth is in approvals.md.
A reader looking at a paused conversation is told, and by its own event. A
refused turn is declined before a placeholder exists, so without one the message
sits in the transcript with no reply and no indication, and the thread view
cannot read /v1/inhibitors to work out why. The event is chat.held, carrying
the reason and a resumable flag -- true for a suspension, which runs again of
its own accord when the hold lifts, false for a stop, which waits for a person.
Not chat.error, for the reason the Cut mid-turn section gives below: a
failure and a stop are not the same thing, a failure is retried and a stop is
not, and the browser's error path files the turn under failures and discards the
reply bubble. A reader shown that for a deliberate pause is told the system
broke. And only when a person is waiting -- a turn with no user_id is nobody's
pending question, and an announcement for one explains nothing anybody can act
on.
When a session was already latched, what is announced is the reason on the latch, not the hold that happens to cover it today. Otherwise a session stopped by one incident and later covered by a second reports the second, while the transcript, the latch and the next turn's marker all narrate the first.
decide is a function rather than anything swappable on purpose: there is one
correct answer and every call path has to get it. A join that could differ
between callers is a kill switch that works in one place and not another.
Why not a flag¶
The obvious version is a boolean on the workspace: suspended or not. It fails the first time two things want it at once. A customer's spend cap trips and sets it; an operator investigating abuse sets it; the customer tops up and clears it; the abuse hold is gone and nobody noticed. A single flag cannot say who is holding it, so releasing is indistinguishable from overriding.
So an inhibitor is a row rather than a bit, and there may be several. Each names what it inhibits, who took it and why. Work proceeds when none apply. This is systemd's arrangement and it is the right one for the same reason: the holder is part of the state, so release is per-holder and the last one out decides.
The other reason is that the same mechanism has to serve cases that look unrelated. A spend kill switch, an operator stopping a runaway agent, and a turn waiting for someone to approve a tool call are the same question -- may this work continue right now -- asked by different parties. Building three mechanisms means three sets of checkpoints, three ways to display "why is nothing happening", and three bugs when they overlap.
The join¶
Each inhibitor contributes one of two strengths, and the verdict is the strongest that applies:
proceed < suspended < stopped
Zero inhibitors is the empty case of the same rule, not a special path.
suspended means the work may continue later. stopped means this turn is
over.
The verdict is derived at each checkpoint, never stored. A turn suspended waiting for an approval, resumed when the approval arrives, must re-evaluate everything -- the workspace's kill switch may have come on while it waited. Storing "suspended because X" and resuming on X's release would walk straight through a stop that arrived in between. What is persisted is that the turn is suspended; which inhibitors contributed is a snapshot for display and audit, not the authority on whether to go on.
The UI shows every contributor, not just the winner. Because stopped takes
priority, a turn both stopped by an org switch and waiting on an approval
reports only the stop -- hiding a request somebody is still expected to answer.
The join picks the outcome; a person needs the whole set.
Levels¶
Inhibitors cascade the way settings do -- platform, workspace, agent -- and for the same reason: an operator needs a switch that no workspace can override, and a workspace needs one that does not require naming every agent. Unlike settings there is no overriding. A level cannot clear an inhibitor held above it; the set is the union of every level that applies, and every holder must release its own.
Where a turn is interrupted¶
Checking anywhere and resuming anywhere are very different costs. The guest already names the one place the loop is consistent:
Injected after the results and before the next model call: the only point in the loop where the conversation is consistent and nothing is half-done.
That is the round boundary, and it is where a suspended turn can be picked up again without inventing state. Tool results are recorded, no call is outstanding, and the transcript is a conversation rather than a fragment.
A stopped verdict is different, and it is checked in two places that answer
different questions.
The gateway cuts the stream. It already polls per-session state every 250ms to carry cancels and steers, so the hold rides that tick: a stop sets the same flag a person pressing stop sets, and the provider connection closes. This is what makes the switch worth having. A round boundary can be a whole completion away, and every token until then is spend past a cap that has already tripped -- which is an arithmetic problem rather than a security one, and applies to a perfectly well-behaved guest.
The guest stops at its round boundary. The gateway's trailer tells it the turn was cut and why, and it returns at the next boundary keeping what it wrote. This is the tidy half: tool results recorded, no call outstanding, a transcript that reads as a conversation.
Neither is a layer of the other. One stops the spend now; the other stops without making a mess. Whichever arrives first does its job.
Only a stop cuts a stream. A suspended turn is one that will be picked up again, and cutting it mid-token is how a resumable turn becomes a broken one.
The gateway's check fails open, like everything else it cannot look up: a database it cannot reach stops nothing rather than stopping everything. That is the wrong way round for a spend cap and the right way round for an outage. What it misses, the turn-preparation check catches on the next turn -- unless the hold is gone by then, which is why a cut turn latches its own session rather than leaving that to the turn after it.
The latch¶
A stop persists until a person restarts it. Flipping the workspace switch back on does not resume anything, and neither does anything the agent does. This is what makes it a kill switch rather than a pause with a harsher name.
What clears it is a message carrying a real user_id. agent_messages.user_id
is null for anything the platform produced and set when an account sent it, so
the agent cannot clear its own latch, a steer it provoked cannot, and neither
can a background job. role = 'user' would not do: mid-turn steering already
arrives wearing that role.
The latch is session-scoped -- a column on agent_sessions rather than
something inferred from the last turn's outcome. Inferring it makes "stopped"
and "the last turn happened to fail" indistinguishable, and a session that went
quiet for a reason should be able to say so. One workspace switch stopping fifty
conversations needs fifty deliberate restarts, which is the intended cost: a kill
switch that un-kills in bulk is one nobody can reason about afterwards.
Markers¶
A stop has to be legible in the conversation, not only in the latch column. The latch governs whether work may proceed; the marker tells the model what became of the work that did not. Both are needed and they are not the same requirement.
There are two situations and they need different words. Conflating them produces the failure the marker exists to prevent.
Stopped before the turn ran. Nothing started, so there is no partial reply -- the transcript is a prompt followed by silence. What the next turn needs is not "your reply was cut off" but that a message went unanswered and why. Telling a model its reply was stopped when it never wrote one invites it to apologize for a fragment that does not exist.
Stopped mid-flight. There is a partial assistant message, and the next turn replays it. Without an explanation the model reads its own reply trailing off and either apologizes or tries to finish the abandoned thought. So it is told the reply stops partway, and to carry on from there if that still makes sense.
The transcript says which happened, so nothing has to be recorded to tell them apart: a turn stopped before it ran leaves the person's message newest, and one cut mid-flight leaves the agent's. The reply has to have something in it -- a placeholder exists from the moment a turn starts, and an empty one is the silence case wearing the other shape.
What the restart answers¶
Clearing the latch takes a message from a person, and that message starts a turn. But the prompt that was refused is still sitting there unanswered -- so somebody who asks a real question, is stopped, and comes back later with "hello?" would get an answer to "hello?" while the question is never addressed.
The restart turn sees both. This is the batch resume a suspended turn already
needs: injected() formats several arrivals as "mid-turn message N of M;
respond to each of the M in order", and an unanswered prompt plus the message
that restarted it is that list with two entries.
Stopping is lazy¶
A workspace hold does not latch its sessions when it is taken. Each session latches as it next tries to run, which means a workspace with fifty conversations has fifty latches only if all fifty were attempted while the hold was on.
This is the right trade -- nothing walks a workspace's sessions on a write, and a conversation nobody touched needs no restarting -- but it makes the earlier claim more precise. The cost of a kill switch is a deliberate restart per conversation that tried to continue, not per conversation that exists.
A turn cut mid-stream is the exception, and it has to be. It latches on the turn that was cut rather than the next one, because the hold may be released before there is a next one -- and a session that was never latched simply carries on, which is the whole thing a stop is supposed to prevent. That latch write fails the turn if it fails, rather than being logged and shrugged off: a stop that did not record itself is a kill switch that silently did not take.
That holds whether the cut turn then ends tidily or falls over. A failure and a stop look identical from outside -- both end a turn with no reply -- and they are not the same thing: a failure is retried, and a retry that finds the hold released runs the work the hold existed to prevent. So the reason travels out of the runtime on the error path as well as the successful one, and the latch is written before the failure is reported.
Input is never blocked¶
A suspended turn keeps accepting messages. Refusing them is the one place the agent would suddenly stop listening, and a composer grayed out because the agent is waiting on a human reads as broken however deliberate it is.
Messages arriving while suspended queue rather than being folded into a running turn, because while suspended there is no guest running to poll for them. Resume considers the whole queue at once -- the existing injection already formats several arrivals as "mid-turn message N of M; respond to each of the M in order", so this is the built path with a longer list.
Nothing is superseded and nothing is interpreted. An earlier draft had a new message cancel a pending approval, on the reasoning that "actually, forget it" should not let a deletion through. It decides consent by reading prose: "hurry up" or "any luck?" would silently kill a request nobody withdrew. The human resolves the hold explicitly, and until then agent progress waits while input does not. That is what a gate is.
Human in the loop¶
HITL is an inhibitor of strength suspended, held by a pending request and
released when somebody answers it. It is not a tool result. Which is what makes
the hold the truth of whether a request is still open, rather than a column
somewhere else saying so -- see action-queue.md for who gets
asked and how they find out. A denial delivered
as a tool result launders a governance decision through the model's context,
where the model can narrate it, misreport it or carry on -- and a gate that only
fires when a tool happens to be called is not a gate.
An approval mints a capability that the retry carries, rather than flipping the request to approved and hoping the same path is taken. The shape is already in this codebase twice: an egress rule records the skill whose declaration opened it, and a turn carries a gateway token minted for that turn alone. A grant with provenance, narrow in extent, is the pattern.
What the capability covers is designed in
approvals.md, which settles the default at one call and its
retries, keyed as docs/idempotency.md derives keys, with a wider extent offered
by the skill and ticked by the approver rather than inferred. What follows was
the reasoning that got there, and the test in it is the one that decides whether
an offer is honest. A single request, a predicate over requests, or an authority
conferred for a while. It does not
block the work below, because a single-request key is the narrowest case of a
scoped one: if the key carries a scope from the start, widening it later
reshapes nothing.
The hazard in widening is the classic permission-dialog failure -- a person approves the instance they were shown, not the class it belongs to. Somebody reading "delete /tmp/build-cache" and clicking approve has consented to that, and a system that reads it as "file deletion" has invented consent nobody gave. The test that sorts them: would a person seeing two different instances of this permission ever answer differently? Reaching one host twice is the same act both times, so approving the host is honest -- which is why the egress model works. Deleting a different file each time is not.
A category grant, if it exists, is therefore ticked deliberately by the approver and never inferred from what was asked. And it needs a stated extent -- this call, this turn, this session, until revoked -- because a key without one is a standing grant, and a standing grant is an authority that the roles UI cannot see.
Who may approve, and of what¶
Two questions that look like one.
May this person answer approvals at all is an ordinary authority, and gets
one. Which roles hold it is settled in approvals.md -- admin
in the templates and deliberately not operator, since an operator builds the
agents that raise these -- rather than restated here, because this document said
"most likely admins and the operators" and the two then disagreed about the same
seed.
Is the approver entitled to the thing being approved is the harder one, and
the answer depends on who owns the concept. Where the authority is one of ours,
requiring it is right and its absence is a real escalation: somebody approving a
workspace file write they could not perform themselves has lent the agent an
authority nobody gave them. Where it belongs to a remote system -- "create work
ticket" in somebody's ticketing API -- it is not ours to check. The vocabulary
in rbac.rs is fixed and code-defined; a customer's idea of who may dispatch a
technician was never in it and cannot be.
So for a remote call the approval is what it says it is: a person who holds
approvals:answer looked at this and said yes, recorded with what they saw.
Modeling more would mean maintaining a mapping from every integration's
operations onto authorities we do not own, to make a guarantee the remote system
never asked for -- it either checks the credential the workspace supplied or it
does not.
Two smaller notes for whoever builds this. A workspace could be given
authorities of its own -- strings meaningless here and meaningful there -- which
is a small schema change and a large conceptual one; worth doing only once
something needs it. And an integration's endpoints reach the world through
egress, which approves a host: a workspace that allowed api.example.com has
allowed every endpoint on it, which is why a per-request gate is a separate
mechanism rather than an extension of that one.
Tagging endpoints by risk is the right shape applied too early. It is meaningful once there are endpoints to tag and one can see whether they sort into groups or each want their own answer; invented beforehand it is a taxonomy fitted to nothing.
Rate limits take inhibitors¶
Everything above is a switch: somebody decides to stop something. What is
missing is a governor -- the thing that catches a runaway nobody is watching, at
three in the morning, before anyone thinks to look. max_tool_rounds is the
only guard today and its own description says what it is not: "a runaway guard,
not a budget: what costs money is tokens". It bounds one turn and says nothing
about a thousand.
The case to design for is an invoice-processing agent that should work at a steady pace, spawning a hundred sessions that each loop and spend ten thousand dollars in a few minutes. Every session looks reasonable on its own. So the bucket is per agent, across all its sessions -- a per-session or per-turn limit misses this entirely, because the damage is in the aggregate and each part of it is unremarkable.
A token bucket rather than a ceiling per hour, because "burst but do not rampage" is exactly what a refill rate and a capacity encode separately. A fixed ceiling either blocks legitimate bursts or sits so high it never fires.
Tokens across all models, and the clunkiness is accepted. An Opus token and
a Haiku token cost wildly differently, so this is a crude proxy for money -- and
a fine one for a runaway guard, which is what it is. Pricing stays out, for the
reason docs/usage.md gives.
Charged on completion, admitted optimiztically. A call's cost is known only after it returns, so the bucket is always a little behind and a single enormous call can overshoot. Against a looping agent spending five figures, one call of slack is a rounding error, and the alternative -- reserving an estimate and reconciling -- buys accuracy nobody needs here.
The gateway is where it belongs: the only tier that sees every model call, holds a database connection, and can refuse before the tokens are spent. It already polls per-session state on a tick and already cuts streams, so a bucket that empties mid-stream uses machinery that exists. Note the turn token names a workspace and a session but no agent, so a per-agent bucket resolves the agent from the session the way the mid-flight check already does.
An exhausted bucket takes an inhibitor. It is a machine holder with a reason -- "token bucket empty: 2.1M tokens in the last hour" -- and everything below it is already built: the join, the latch, the panel, the release. That also answers what a machine-held inhibitor looks like, which was an open question.
What is undecided is whether the hold lifts when the bucket refills or waits for a person. Auto-release makes it a governor and manual makes it a tripwire; the likely answer is auto-release with a record, so a rampage is visible afterwards even though work resumed on its own.
Order of work¶
Kill switches first, without HITL. They are pure stopped: no resume, no
capability, no category question. They still exercise every structurally hard
part -- where the checkpoints sit, the join, the latch and what clears it,
truncating a round mid-stream, and making a stop legible to the next turn. Once
that spine is proven, HITL is suspended plus resume plus the capability, laid
on machinery that already works.
The other order means debugging suspension and capability scoping at the same time, against checkpoints that have never fired.
Notifications after that. They look independent and are not: until inhibitors exist there is nothing to notify about, and what the notification system has to carry is decided by what generates the events.
Partly built ahead of that advice, and action-queue.md records what it cost: the queue that shows a person what is waiting on them exists, with role-valued targeting and one read across workspaces, but it points at an event rather than at the hold and carries a lifecycle column the hold should own. That document settles which is the truth -- the hold -- and what has to change when suspension arrives.
The approval rule waits for something to point at. Now it has one: approvals.md puts the declaration in the skill file that documents the operation, which skill-packages.md made versioned, immutable and editable only under the skill's own authorities. HITL's mechanism -- suspend, hold, resume -- is independent of what triggers it and can be built whenever. How an integration's endpoint declares that it needs approval is not: designing that without a single real endpoint in front of you is how a schema comes out fitting nothing. The OpenAPI work comes first, with no approval concept in it at all, and the rule is designed afterwards against operations people actually want gated.
Not yet¶
- The capability's extent, above.
- Whether an exhausted rate limit's hold lifts on refill or waits for a person.
- Where a rate limit is configured. The settings cascade is the obvious home, but a bucket has two numbers rather than one and the cascade takes scalars.
- Whether a suspended turn can be stopped by timeout, or waits indefinitely. Waiting is the honest default and costs nothing while input is unblocked.
- What an inhibitor costs to display when it is held by a machine rather than a person -- a spend cap tripping wants to say which cap and at what figure, and that is the holder's to supply.