dispatch

package
v0.2.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package dispatch is the runner/v1 attempt lifecycle, written once.

A runner executes attempts, and pkg/runner/client reaches one. Everything that happens between a dispatch and its answer — following the attempt to the end, telling a park from a finish, reading a harness's stream into text, tools and cost, answering a question or a permission prompt, stopping an attempt and SEEING it stop — had been written twice, once per product, and the two copies drifted. The agents provider learned that a terminal event can arrive before the terminal receipt; Factory's copy still believed the event. Factory refused a receipt whose session id had changed; the agents copy knew a harness forks its session on a resume. Each bug was fixed in one place and stayed in the other.

So the rules live here, and the products keep only what is theirs: an agent's transcript and inbox, Factory's approved envelope and its delivery. This package knows about neither. It knows the protocol.

The rules it holds:

  • The RECEIPT is the authority on how an attempt ended. A terminal event is a reason to go and read it, never a conclusion in itself.
  • The receipt's session id is authoritative, because a harness may fork its session on a resume. Whatever is chained next is chained onto what the receipt said, never onto what was sent.
  • A cursor the runner no longer holds is reconciled through Inspect, not failed over: the turn is still working, our view of it is what is stale.
  • A park is one of two things — a QUESTION answered with words, or a PERMISSION prompt answered with a verdict on a named call — and they resume through different fields that are mutually exclusive on the wire.
  • A cancel is not done until the runner's receipt says cancelled. Reporting it earlier describes something nobody has observed.
  • "attempt not found" is the ONE answer that permits a first start. Every other failure to read an attempt may be hiding accepted work.

What this package deliberately does not do: it never persists anything, it decides nothing about who may answer a park, and it holds no credential past the call it was given for. A caller that needs durability writes what Outcome and Position tell it; a caller that needs an answer asks its own users. Observer is how the stream reaches a caller as it happens, and every method on it is optional.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Cancel

func Cancel(ctx context.Context, r Runner, req runner.CancelRequest) (*runner.Receipt, error)

Cancel stops an attempt executing on the runner, and then OBSERVES that it stopped.

The observation is the point. An attempt on somebody else's machine is `cancelling` until its harness child has actually been reaped and its terminal receipt says `cancelled`; returning before that would report a cancellation nobody has seen, and the next thing that read the attempt would find it still running. The caller bounds this through ctx; when the bound runs out the error says the attempt is still cancelling, which is the truth.

A nil receipt with a nil error means there was nothing to cancel: the runner never heard of the attempt, or it had already ended.

func Describe

func Describe(what string, err error) error

Describe wraps a runner failure with what was being attempted, keeping the original for errors.As: callers branch on *runner.Error's Code and on *client.HTTPError's status, never on a message.

func Ended

func Ended(receipt runner.Receipt) error

Ended renders a terminal receipt as the error it stands for: nil for a completed attempt, and for a failed or cancelled one an error carrying what the runner said. Callers that treat "the harness stopped" and "the harness broke" differently check the receipt's Phase; this is for the message.

func IsAttemptUnknown

func IsAttemptUnknown(err error) bool

IsAttemptUnknown reports the ONE answer that permits a first start: the runner, reached and authenticated, says it has never heard of this attempt.

It is a protocol answer rather than a transport outcome, and the difference is the whole fence. A timeout, a 503 or an empty body may be hiding work the runner accepted and is doing; only this says there is none.

func IsNothingToCancel

func IsNothingToCancel(err error) bool

IsNothingToCancel reports a cancel that had nothing to stop: an attempt the runner never heard of, or one whose epoch is already obsolete because it finished. Neither is a failure to cancel — there is nothing running.

func IsSnapshotRequired

func IsSnapshotRequired(err error) bool

IsSnapshotRequired reports a cursor the runner no longer holds. The caller's view is incomplete and only Inspect can restore it; Follow does that itself, and this exists for a caller driving the primitives by hand.

func IsUnstartable

func IsUnstartable(err error) bool

IsUnstartable reports a refusal that the same request can never get past: the runner does not offer what the request requires. Retrying the same bytes is pointless; the caller's remedy is to choose differently.

func Refusal

func Refusal(err error) (*runner.Error, bool)

Refusal extracts the protocol error a runner answered with, if it did.

The shared client already decides this: a failed response whose body carries a protocol Code comes back as *runner.Error, and everything else — a timeout, a proxy 502, a kcp Status, an empty body — is an HTTPError or a transport error. So the distinction every dispatch fence rests on — "refused, nothing ran" against "outcome unknown" — is a type assertion here and not a hopeful unmarshal of whatever bytes came back.

func ResumeRequest

func ResumeRequest(requestID string, receipt runner.Receipt, pos Position, answer Answer, approved json.RawMessage, credential *runner.HarnessCredential) runner.ResumeRequest

ResumeRequest builds the resume that answers the park the receipt describes.

The receipt is authoritative for what is being answered — it is what the runner is waiting on right now — and pos is the fallback for a resume that could not re-read it. The two park shapes are mutually exclusive on the wire, so exactly one of ClarificationID and PermissionID is ever set: a permission prompt is answered with a verdict on a named call, and setting that clears any question id so the resume never claims to be both.

The credential and approved input are the caller's: both must be sent again on every resume, because a resume may arrive after the runner restarted and nothing about the caller's identity survives on the host.

Types

type Answer

type Answer struct {
	// Resolution is the text of the answer, or the note on a verdict.
	Resolution string
	// Allow is the verdict on a permission prompt. Ignored for a question.
	Allow bool
}

Answer is a caller's resolution of a park.

For a QUESTION, Resolution is the words. For a PERMISSION prompt, Allow is the verdict and Resolution is the person's own note, which reaches the model as the reason — never a stock phrase, because "Approved. Continue." is an instruction for a new turn and means nothing to a tool call that is waiting.

type AssistantObserver

type AssistantObserver interface {
	Assistant(AssistantResult)
}

AssistantObserver is an optional Observer extension. It is called only when the receipt confirms successful completion; incomplete assistant output is already available through Text.

type AssistantResult

type AssistantResult struct {
	Content  string
	Complete bool
	Duration time.Duration
}

AssistantResult is the completed assistant response and its active model time. It is an optional observer extension for consumers that bill or render model timing separately from tool timing.

type Noop

type Noop struct{}

Noop satisfies Observer and does nothing. Embed it to implement a subset.

func (Noop) Aborted

func (Noop) Aborted(context.Context) error

func (Noop) Assistant

func (Noop) Assistant(AssistantResult)

func (Noop) Checkpoint

func (Noop) Checkpoint(Snapshot)

func (Noop) Text

func (Noop) Text(string)

func (Noop) ToolEnd

func (Noop) ToolEnd(ToolResult)

func (Noop) ToolStart

func (Noop) ToolStart(string, string, string)

type Observer

type Observer interface {
	// Text receives prose the harness produced, in order. A caller showing a
	// person the turn streams it; one that only records it concatenates it.
	// Summary.Text is the same prose, accumulated, so a caller need not.
	Text(delta string)
	// ToolStart announces a tool call the harness began. ID correlates it
	// with its ToolEnd. A call the harness reported in one piece produces
	// both at once.
	ToolStart(id, name, args string)
	// ToolEnd reports a tool call finishing. An attempt that ended with a call
	// still open produces a ToolEnd for it with Failed set and Result saying
	// so, so a transcript never holds a call open forever.
	ToolEnd(ToolResult)
	// Checkpoint offers a point the attempt can be re-joined at — nothing is
	// half-consumed when it fires. A caller that survives its own restart
	// persists it; one that does not ignores it.
	Checkpoint(Snapshot)
	// Aborted is consulted at every point the follow could stop cleanly: before
	// each reconnect and between quiet streams. A non-nil error stops the
	// follow with that error and Outcome.Receipt as last read. This is how a
	// caller's own durable cancel flag — one that outlives the context it
	// handed in — reaches the loop.
	Aborted(ctx context.Context) error
}

Observer receives what an attempt's stream says, as it says it.

Every method is optional in the sense that Noop satisfies all of them: a caller that only wants the Outcome passes Noop and gets exactly that. A caller with a transcript to keep, or a cancel flag of its own to honour, implements the methods it needs and embeds Noop for the rest.

Nothing here is a verdict. The stream is reported, not interpreted: whether a tool call should have been allowed, or whether a question is one the caller's users may answer, is the caller's business after Follow returns.

type Outcome

type Outcome struct {
	Receipt  runner.Receipt
	Position Position
	Summary  Summary
	Parked   *Parked
	// Snapshot is the complete normalizer state at Position. It is useful when
	// the attempt parks before the next quiet-stream checkpoint.
	Snapshot Snapshot
}

Outcome is how a follow ended. Receipt is the authority; Position is where the attempt stands; Parked is set when the receipt is needs_input.

func Follow

func Follow(ctx context.Context, r Runner, receipt runner.Receipt, from Position, obs Observer) (Outcome, error)

Follow tails an attempt's events from a position to a terminal phase or a park, reporting them through obs, and then reads the receipt — which is the authority on how it ended.

The loop reconnects, and that is not a retry loop: the runner ends a stream itself after a silent interval so a caller resumes from its cursor rather than holding one connection open for the length of a coding turn. Each reconnect is also where the two things that must not be skipped happen: the caller's cancel flag is consulted, and the receipt is re-read in case the attempt ended during the quiet.

func FollowSnapshot

func FollowSnapshot(ctx context.Context, r Runner, receipt runner.Receipt, snap Snapshot, obs Observer) (Outcome, error)

FollowSnapshot continues from a persisted normalizer snapshot. The summary includes prior text and final output; Summary.Usage is only the spend since the saved cursor, while the returned Snapshot.Usage is cumulative.

func Rejoin

func Rejoin(ctx context.Context, r Runner, pos Position, obs Observer) (Outcome, error)

Rejoin picks an attempt back up from a position, which is what a caller does after its own process died: it does not know whether the attempt finished, parked or is still working, and Inspect is what tells it.

  • Already over: the receipt is the answer, and resuming it would be a second dispatch of work that is done.
  • Parked: the receipt names the question or the prompt, and the caller decides how to answer. Nothing is sent.
  • Still working: tail it from the cursor. Nothing was lost.

func RejoinSnapshot

func RejoinSnapshot(ctx context.Context, r Runner, snap Snapshot, obs Observer) (Outcome, error)

RejoinSnapshot restores normalized output and dedupe state, inspects the attempt, and reads every retained event after the saved cursor. Unlike a Position alone, this is sufficient for a caller that must return a complete transcript and usage total after its process restarts.

func Resume

func Resume(ctx context.Context, r Runner, req runner.ResumeRequest, from Position, obs Observer) (Outcome, error)

Resume answers a park and follows the attempt onward. Build req with ResumeRequest so the park is answered through the right field.

func ResumeSnapshot

func ResumeSnapshot(ctx context.Context, r Runner, req runner.ResumeRequest, snap Snapshot, obs Observer) (Outcome, error)

ResumeSnapshot answers a park and continues with its accumulated normalized state. The new receipt remains authoritative for the session and epoch.

func Start

func Start(ctx context.Context, r Runner, req runner.StartRequest, obs Observer) (Outcome, error)

Start dispatches a fresh attempt and follows it to the end or to a park.

A caller with a durability fence of its own — one that must write the start receipt down before anything else happens — calls Runner.Start itself and then Follow; this is the convenience for callers without one.

func (Outcome) Completed

func (o Outcome) Completed() bool

Completed reports a successful end.

func (Outcome) Terminal

func (o Outcome) Terminal() bool

Terminal reports whether the attempt is over.

type Parked

type Parked struct {
	Question   *runner.Clarification
	Permission *runner.PermissionRequest
}

Parked describes a needs_input receipt. Exactly one field is set, and the two are answered differently: a Question with words, a Permission with a verdict on the named call. See Answer.

type Position

type Position struct {
	TaskID    string
	AttemptID string
	Epoch     uint64
	SessionID string
	// Cursor is the last event consumed, so a re-join tails from there instead
	// of re-streaming what the caller has already recorded.
	Cursor uint64
	// ClarificationID is what a resume must echo to answer the QUESTION the
	// harness asked; PermissionID is what it must echo to answer a PERMISSION
	// prompt. They are different parks that resume differently, and at most
	// one is set.
	ClarificationID string
	PermissionID    string
}

Position is where an attempt can be re-joined: the coordinates of work in flight, not a snapshot of it. The harness holds the transcript; re-joining is addressing the same attempt at the same cursor.

Every field a receipt reports is taken from the receipt when it is set, and the session id in particular is AUTHORITATIVE: a harness may fork its session on a resume, and chaining onto what was sent would continue a conversation nobody is having.

func (*Position) Observe

func (p *Position) Observe(receipt runner.Receipt)

Observe folds a receipt into the position: every identity the receipt reports is taken from it when set.

NOT the cursor. A receipt's cursor is the RUNNER's head — how far it has got — and Position.Cursor is how far the CALLER has consumed. Taking the receipt's would skip every event between the two, which is exactly what a re-join exists to read. The cursor advances only as events are consumed, and jumps only on a reconcile after a cursor gap, where the skipped events are already gone.

type Runner

type Runner interface {
	Start(ctx context.Context, req runner.StartRequest) (runner.Receipt, error)
	Resume(ctx context.Context, req runner.ResumeRequest) (runner.Receipt, error)
	Cancel(ctx context.Context, req runner.CancelRequest) (runner.Receipt, error)
	Inspect(ctx context.Context, attemptID string) (runner.Receipt, error)
	Events(ctx context.Context, attemptID string, after uint64) (Stream, error)
}

Runner is the runner surface the lifecycle needs.

It is an interface for one reason: *client.Client reaches a real machine through kcp and an edges Service proxy, and a test that had to stand that up would be testing the plumbing instead of the rules. Every method is a method of *client.Client with the same name and signature, so this is a seam rather than an abstraction — Wrap is the only implementation that speaks to a runner.

func Wrap

func Wrap(c *client.Client) Runner

Wrap adapts the shared runner client to Runner. The only adaptation is Events, whose concrete *client.EventStream already satisfies Stream.

type Snapshot

type Snapshot struct {
	Position Position
	Usage    Usage
	// State is the opaque, versioned state of the event normalizer. Callers that
	// only need to rejoin the runner may omit it; callers that must preserve a
	// transcript and dedupe state should persist the complete Snapshot.
	State json.RawMessage
}

Snapshot is a Position plus what the follow had accumulated when it was taken. It is what Observer.Checkpoint offers.

func RestoreSnapshot

func RestoreSnapshot(ctx context.Context, r Runner, pos Position, obs Observer) (Snapshot, error)

RestoreSnapshot reconstructs the normalized event state through pos.Cursor from the runner's retained journal. It is the migration path for older saved states that stored only coordinates and cumulative usage. Replays are silent; obs is consulted only for durable cancellation.

type Stream

type Stream interface {
	Next(ctx context.Context) (runner.Event, error)
	Close() error
}

Stream is one live read of an attempt's events. Next ends in io.EOF when the runner closes a quiet stream, which is ordinary: the runner does that after a silent interval so a caller resumes from its cursor instead of holding one connection open for the length of a coding turn.

type Summary

type Summary struct {
	// Text is every piece of prose the harness produced, in order.
	Text string
	// Final is the harness's own answer record when it produced one, and
	// otherwise the text. It is the presentation answer.
	Final string
	// Usage is what THIS follow saw reported. A caller re-joining an attempt
	// adds what it had already recorded.
	Usage Usage
}

Summary is what the stream said, accumulated, so a caller need not.

type ToolResult

type ToolResult struct {
	ID     string
	Name   string
	Args   string
	Result string
	Failed bool
	// Duration is the elapsed time reported by the harness, when available.
	Duration time.Duration
}

ToolResult is one finished tool call.

type Usage

type Usage struct {
	InputTokens  int64
	OutputTokens int64
	CostMicros   int64
}

Usage is what an attempt reported spending. Where a harness reports tokens and no price the cost stays zero: a number invented from a token count and a guessed rate would be wrong in a billing column.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL