Skip to content

multi-actor worker API - #1283

Draft
Benjamin Elder (BenTheElder) wants to merge 8 commits into
agent-substrate:mainfrom
BenTheElder:worker-assignments-as-rows
Draft

multi-actor worker API#1283
Benjamin Elder (BenTheElder) wants to merge 8 commits into
agent-substrate:mainfrom
BenTheElder:worker-assignments-as-rows

Conversation

@BenTheElder

@BenTheElder Benjamin Elder (BenTheElder) commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Part of #1266

This is a draft of the core API + data store changes.

It's still a large PR, apologies.

The "as rows" commit could be split out, but this takes it to ~all of the breaking changes we can't hide behind updating internals.

Same for the claimlock, but in both cases it seems these are worth understanding when considering the API shape.

They're loadbearing for performance once we actually have multi-actor workers.

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

x-ref: #1164 🙈

status.assignment held one Actor -- the implementation's limit written into the
wire format. Describe a set now, while there is one implementation to keep in
step, and leave the implementation admitting one.

assignments is repeated and reuses field 2, which is wire-compatible with the
singular field it replaces, so an old record reads back as a set of one. How
many may be bound is capacity.actors, a dimension alongside cpu_milli and
memory_bytes because it bounds the same thing they do -- what one more Actor
costs -- for the costs that are not compute. Unset means ONE, so nothing admits
more until a Worker says it can.

status.allocated is what the assignments took, as a running total: placement
reads it for every Worker on every decision, and summing the list there would
cost the fleet's whole actor count. ActorAssignment.resources records what each
Actor was admitted for, so a release gives back what its bind took.

Capacity also stops being immutable -- a pod can be resized, and the actor
ceiling is the Worker's own. Clearing it is still refused.
Mostly mechanical: readers of status.assignment go through
resources.WorkerAssignmentFor, and bind/release keep status.allocated in step
with the list they count.

Three that are not.

Placement asks what is left rather than whether the Worker is empty, so
eligibility splits from room. A caller re-validating a Worker that already holds
the Actor must not ask about room: the Actor's own resources are already counted
against the Worker, and it would be evicted from a valid placement.

ListWorkers leaves the assignments empty and reports occupancy through
allocated, so listing the fleet costs its size and not its actor count.
GetWorker carries them.

kubectl-ate prints ASSIGNED(n/m) instead of naming the Actor, and resolves
--atespace by asking the Actors which Worker they are on. The
claude-code-multiplex demo loses its template-namespace filter for the same
reason: a listing has no template to filter on.
capacity.actors has no writer, so every Worker sits at the unset default of one.

The number is the ateom's: it owns the slot allocator, and only the node the
Worker runs on can observe it. A WorkerPool cannot state it either, because two
Workers in one pool running different ateom builds do not have the same answer.
So it arrives by report rather than by assertion, and a fleet may run mixed
ateom versions with each Worker admitting what it can actually take.
ReportWorkerCapacity sits on ActorIdentity for the authorization that service
already establishes: atelet calls it for the Worker with its own client
certificate, as it does for MintCert, and may speak only for the Workers on its
node. A Worker on another node is NOT_FOUND, not PERMISSION_DENIED -- a caller
learns nothing about Workers elsewhere.

A report states only the dimensions its sender can see, so an unset one keeps
what is recorded rather than clearing it. The syncer carries the reported
ceiling across its own recomputes for the same reason: it owns cpu and memory
and would otherwise clear the report on every pod event, forever.

Re-sending an unchanged capacity is not an update, so a reporter on a timer
costs a read.

No caller yet: that belongs with an ateom that has something other than one to
say.
A Worker carried its assignments in its own record, so the record, its change
event, and every watcher's copy of it all grew with the Actors on it. Give each
assignment a row instead.

Only status.allocated stays on the Worker: placement reads it for every
candidate on every decision, so it has to be there. The store moves it in the
same transaction as the assignment it counts, and ClearAssignments drops the
list on the way in, so there is one place the truth lives.

Binding does not read the assignment it might replace. The ordinary bind is a
first bind, and the insert landing is proof nothing was there. Deciding it on
the write is also what makes it correct: a read cannot see a claim that commits
after it, so two replicas claiming one Actor onto two Workers would each find
nothing and each add one, leaving the loser counting an Actor it does not host.
The contract pins that, and it fails against a read-first bind.
Claiming a worker rewrites its whole record. That suits racing ateapi replicas
but not racing goroutines, which is what a worker hosting thousands of actors
makes them: on an empty worker this refused 21% of activations at 12 in flight
and 67% at 24.

The store already binds inside one transaction with the worker's row locked, so
it is the only place that can decide whether the worker has room without the
answer going stale before the write. BindActorToWorker takes an admit callback
and asks there. Refusing rolls the transaction back, which is also what takes
the speculative assignment row out again.

Deciding it there is what removes the race rather than detecting it: no version
precondition to lose, no re-read of the worker that is stale by the time it is
used, and no lookup asking whether this worker already holds the actor, which
the insert answers on its own. The row lock covers every replica, which no
in-process lock can.

The retry budget still had to grow. A claim that is refused for want of room
re-runs scheduling, and that was five steps from 10ms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api User-facing API changes area/node

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant