Repository navigation
design dispatch stance
The dispatched-agent stance today is the MACHINE default (chatApprovals.responder):
every agent_dispatch inherits it, and the dispatcher cannot say "this run is
unattended" or "pin this one down to manual". This doc adds the per-dispatch
declaration and its failure contract. It rides #92/#95 (delegated approvals +
the resolver chain) and #100 (capability health).
Related: design-agent-dispatch.md (the coordinator), design-delegated-approvals-v2.md
(the gate), design-capability-flags.md (the breaker the probe consults).
Design-stage siblings, not dependencies: #101 (permission-card bridge), #102
(pending-attention ball).
- A manual-default machine makes every dispatch an ATTENDED run: cards sit in the driven channel's tab; if nobody is watching, the 60s watchdog cancels them and the run burns.
- A delegated-default machine cannot be pinned DOWN for a sensitive one-off.
- #67's cron dispatch lists "unattended permission stance" as a precondition; with machine defaults only it is unexpressible — a job would have to mutate machine config per run.
dispatch() input and the agent_dispatch tool gain
approvals?: 'manual' | 'delegated'.
- Absent = machine default. Today's behavior, unchanged.
- No stickiness. A dispatch opens its own channel each time (the resume arm is the AGENT's session, not the channel); a continuation dispatch re-declares. Nothing is persisted.
-
manualalways engages. It is strictly safer than the default.
An explicit delegated is a declared contract: "this run is unattended." If
delegation cannot engage — no resolvable decision model (machine override →
user default slot, the #95 chain), or the #100 breaker holds the slot
degraded — the dispatch REFUSES before opening the channel
(DISPATCH_APPROVALS_UNAVAILABLE). The alternative (silent manual fallback)
was rejected: an unattended run would spawn, raise cards nobody watches, and
die to the watchdog card-by-card — the contract violation hides inside the
run. On the tool path the refusal is the tool result; the model learns and
either re-dispatches with manual explicitly or tells the user.
The entry probe (machine row's resolvable model × breaker health) runs BEFORE
chat.open — no channel, no spawn, no wasted boot. The actual switch rides
the ready tap, before the prompt goes out: the same chat.setApprovals
the browser switch uses. It re-validates; a race that fails there (the model
was deleted between probe and ready) finalizes the record failed and CLOSES
the channel explicitly (a failed finalize does not auto-close the way
done does).
Forced manual needs no probe (it cannot fail); it still switches at the
ready tap so the stance is in effect before the first card can exist.
The model, either direction. agent_dispatch is in the write class — every
call crosses the assistant's M3 card, and the approvals param is visible
there: that card is the consent point. Forcing manual is strictly safer;
forcing delegated is what the user asked the model to do.
The job queue calls the coordinator directly with approvals: 'delegated' —
the "unattended permission stance" precondition closes with zero new
concepts. The job schema can REQUIRE the field: a cron dispatch forcing
manual is a contradiction in terms.
| dispatch stance | machine default | degraded breaker |
|---|---|---|
| absent | inherited | open-time view reads manual (existing #100 honesty) |
manual |
overridden to manual | engages (manual needs nothing) |
delegated |
overridden to delegated | REFUSED before open (fail fast) |
Commit 03058ab on feat/103-dispatch-stance (stacks on the unpushed
#97/#99/#100 stack). Touches chat.ts (delegatedStanceOfferable — the
public probe: resolves instance → machine, answers true for a missing or
foreign instance so open() keeps its own honest codes, then
breaker-degraded → false, then the #95 resolver chain), assistant-dispatch.ts
(entry refusal + stance on the record + the ready-tap switch — an async
apply before onMessageSend; a refusal or throw there finalizes failed
and explicitly closes the channel, since a failed finalize does not
auto-close the way done does), tools.ts (the param + description +
passthrough), and one line in the shared default prompt's dispatch section.
Integration tests (5 new, assistant-dispatch.test.ts, 18 total in file;
server 607, shared 143, typecheck clean):
- forced delegated without a resolvable model →
DISPATCH_APPROVALS_UNAVAILABLEwith NOchat:session.startto the daemon (the refusal predates the spawn); - forced delegated engages BEFORE the prompt (event order on the daemon:
chat:approvals.setwith the resolved view, thenchat:message.send) and the run completes normally; - forced
manualoverrides a delegated machine default (ctl view{responder:'manual'}) and the run completes; - a degraded #100 breaker (3 failures) refuses up front; the breaker is a per-owner cooldown, so the test resets with one success;
- the ready-tap race — the provider deleted between dispatch and ready —
finalizes
failedwithapprovals stance delegated unavailable (CHAT_APPROVALS_NO_PROVIDER)and closes the channel with reasondispatch.
Two test-infra notes worth remembering:
-
test/helpers.ts testConfigwas MISSING the two breaker keys —new ModelCapabilities({threshold: undefined, …})never trips, which is why the degraded test first "passed" a dispatch. The literal now carriescapabilityBreakerThreshold: 3, capabilityBreakerCooldownMs: 600_000. (Tests are outsidetsconfig'sinclude: src/**/*, so no type error ever flagged the incomplete literal.) - fire-and-forget closes from earlier tests can land late: assert "THIS channel's close arrived" (collect + waitFor), never "the next close event is mine".
Deployed: server + web rebuilt and rolled on the local verification stack
(web rebuild because the shared default prompt — one new line — is bundled
into the web build for the config dialog); container code verified by grep
and web index.html md5 parity with the local dist.