Skip to content

design dispatch stance

sinrimin edited this page Oct 30, 2026 · 2 revisions

Per-dispatch approvals stance — issue #103

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).

1. The gap

  • 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.

2. Design

2.1 The parameter

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.
  • manual always engages. It is strictly safer than the default.

2.2 Fail fast, never silently fall back

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.

2.3 Application point

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.

2.4 Who may set it

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.

2.5 Cron (#67, future)

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.

3. Interaction with #100 (breaker)

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)

4. Verification — shipped

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_UNAVAILABLE with NO chat:session.start to the daemon (the refusal predates the spawn);
  • forced delegated engages BEFORE the prompt (event order on the daemon: chat:approvals.set with the resolved view, then chat:message.send) and the run completes normally;
  • forced manual overrides 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 failed with approvals stance delegated unavailable (CHAT_APPROVALS_NO_PROVIDER) and closes the channel with reason dispatch.

Two test-infra notes worth remembering:

  • test/helpers.ts testConfig was MISSING the two breaker keys — new ModelCapabilities({threshold: undefined, …}) never trips, which is why the degraded test first "passed" a dispatch. The literal now carries capabilityBreakerThreshold: 3, capabilityBreakerCooldownMs: 600_000. (Tests are outside tsconfig's include: 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.

Clone this wiki locally