Skip to content

feat(dispatch): pivot to all-async dispatch via push-callback#20

Open
randomm wants to merge 3 commits into
mainfrom
feat/async-dispatch
Open

feat(dispatch): pivot to all-async dispatch via push-callback#20
randomm wants to merge 3 commits into
mainfrom
feat/async-dispatch

Conversation

@randomm
Copy link
Copy Markdown
Owner

@randomm randomm commented May 20, 2026

Closes #19.

Summary

Every dispatch tool (dispatch_specialist, dispatch_parallel, adversarial_loop, dispatch_lens_review) is now fire-and-forget from Pi's perspective: tool execute() returns a { jobId } handle in < 100ms, the child runs under extension supervision, and on completion the extension fires pi.sendUserMessage(report, { deliverAs: "steer" }) to push the result back to the parent agent.

The motivating problem: under sync semantics the PM was locked mid-tool-call for the entire duration of any dispatch (often minutes). The user could not /steer, ask questions, or redirect. With async, the PM ends its turn after dispatching → Pi goes idle waiting for the next prompt → user can interact freely → our push delivery wakes the PM when results arrive.

Invariants enforced

Parent agent only sees the child's final assistant text Same bytes sync dispatch would have returned — no transcript dump
Going async adds zero context bloat Envelope is ~165 bytes (header + footer + formatting) per smoke test
Batched orchestrators emit ONE consolidated steer dispatch_parallel/lens review wait for all N to settle, then fire one report
PM never reads transcript files Doctrine in project-manager.md makes this explicit

What's new

  • extension/src/async-jobs.ts — job registry, startJob/startBatch, abort lifecycle, bounded report formatters
  • extension/src/dispatch-status.ts — new dispatch_status (metadata-only snapshot) and dispatch_kill tools
  • extension/smoke-tests/test-async-dispatch.ts — 33 assertions covering single jobs, batched jobs, cancellation, fail reports
  • All four dispatch tools refactored to use the registry
  • Doctrine updated in agents-base/project-manager.md ("Async Dispatch Protocol" section) and pi-prompts/work.md

Test plan

  • tsc --noEmit clean
  • biome check clean
  • test-command-flow (24+ assertions, registers 6 tools now)
  • test-lens-review (18 unit assertions)
  • test-models (11 assertions)
  • test-runs (transcripts list intact)
  • test-progress
  • test-prune (auto-prune still works)
  • test-async-dispatch (33 new assertions)
  • Live: run /work in a real Pi session, verify user can /steer mid-dispatch
  • Live: verify [ensemble:async] user message arrives via steer when child exits

Release-please

This is a feat: commit → minor bump → v0.4.0 once merged.

🤖 Generated with Claude Code

randomm added 3 commits May 20, 2026 14:15
…19)

Every dispatch tool (dispatch_specialist, dispatch_parallel,
adversarial_loop, dispatch_lens_review) is now fire-and-forget from Pi's
perspective: tool execute() returns a {jobId} handle in <100ms, the
child runs under extension supervision, and on completion the extension
fires pi.sendUserMessage(report, {deliverAs: "steer"}) to push the
result back to the parent agent.

This fixes the responsiveness problem: under the old sync semantics the
PM was locked mid-tool-call for the entire duration of any dispatch
(often minutes), so the user could not /steer, ask questions, or
redirect. Now the PM ends its turn after dispatching → Pi goes idle
waiting for the next prompt → user can interact freely → our push
delivery wakes the PM when results arrive.

Invariants enforced (see #19):
- Parent agent only sees the child's final assistant text (same bytes
  sync dispatch would have returned). No transcript dump.
- Async adds zero context bloat over sync — envelope is ~165 bytes
  (header + footer + formatting) per the smoke test.
- Batched orchestrators (dispatch_parallel, lens review) emit ONE
  consolidated steer when ALL members settle — never N out-of-order
  arrivals. Preserves the "I called the tool, I expect one return"
  mental model.
- PM doctrine forbids reading transcript files under ensemble-runs/.

New tools:
- dispatch_status — metadata-only snapshot of in-flight jobs
- dispatch_kill <jobId> — abort a running subagent or batch

Doctrine updates:
- agents-base/project-manager.md "Sync vs Async Task Mode" section
  rewritten as "Async Dispatch Protocol" — describes the
  [ensemble:async] message flow, status/kill tools, anti-patterns
- pi-prompts/work.md gets a one-paragraph "How dispatch works"
  preamble before Step 1
- cancel_task references replaced with dispatch_kill

Smoke test (extension/smoke-tests/test-async-dispatch.ts): stubs
pi.sendUserMessage, asserts startJob returns instantly, exactly ONE
steer fires on completion, body bounded, killJob aborts via
AbortSignal, batch fires ONE consolidated message after all N settle.
33 assertions, all green.
The async pivot mentioned /steer as a way for users to interact with PM
during async dispatch. In practice if PM is mid-turn the user just
waits until they can type (typically <10s), so teaching /steer is noise.
Stay focused on the simple model: PM ends its turn fast, user types
normally when they want to interject.
Two cleanups now that the adversarial loop is programmatic:

1. PM doctrine had a stale "Adversarial Review (MANDATORY)" section
   telling PM to manually orchestrate rounds (dispatch adversarial → if
   issues, dispatch developer → re-dispatch). That predated the
   adversarial_loop tool which encapsulates the entire 3-round saga in
   TypeScript. PM now gets a single instruction: call adversarial_loop,
   wait for the report, surface escalation options verbatim if REJECTED.

2. The escalation message itself was vague ("Halt the workflow and ask
   the user for guidance"). Per the opencode pattern, the user should
   be presented with concrete options:
     (a) authorise another 3-round pass
     (b) accept current state and commit anyway (logged override)
     (c) abandon and rework approach
     (d) take over manually

The tool returns these options in its REJECTED text so the PM just
relays them. No new logic; clearer escalation semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(dispatch): pivot to all-async subagent dispatch via push-callback

1 participant