runner

package
v0.33.0 Latest Latest
Warning

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

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 32 Imported by: 0

README

runner/ — per-session orchestration loop

Status: Wave 6 / Phase F.2.6 (REN-1459). Public package; importable by rensei-tui without depending on the rest of donmai daemon plumbing. Spec: ../../../runs/2026-05-01-wave-6-fleet-iteration/F1.1-runner-contract.md §1 (layout) + §4 (orchestration) + §5 (failure modes). Legacy reference: ../../../agentfactory/packages/core/src/orchestrator/{agent-spawner,event-processor,session-backstop}.ts.

runner/ ties the Wave 2 + Wave 2b building blocks together into the per-session main loop. F.2.8 (daemon wire-up) calls Runner.Run for every claimed QueuedWork and the function does not return until the session is fully terminated, the result has been posted, and the worktree torn down.

Lifecycle diagram

                  rensei-tui daemon (F.2.8)
                          │
                          ▼  qw QueuedWork
              ┌────────────────────────────┐
              │ runner.Run(ctx, qw)        │
              │                            │
              │ 1. Resolve provider        │
              │      registry.Resolve()    │  → agent.Provider
              │ 2. Provision worktree      │
              │      worktree.Provision()  │  → /tmp/.../wt
              │ 3. Compose env             │
              │      env.Compose()         │  blocklist applied
              │ 4. Build MCP config        │
              │      mcp.Build()           │  tmpfile + cleanup
              │ 5. Render prompt           │
              │      prompt.Build()        │  (system, user)
              │ 6. Translate spec          │
              │      translateSpec()       │  capability-gated
              │ 7. Init state.json         │
              │ 8. Spawn provider          │
              │      provider.Spawn()      │  → agent.Handle
              │ 9. Start heartbeat         │
              │      heartbeat.Start()     │
              │10. Stream events           │  → events.jsonl
              │      consumeEvents()       │  → state.json
              │11. Wait for terminal       │
              │12. Tail recovery           │
              │     a. Steering (cap-gated)│
              │     b. Backstop (det. git) │
              │13. Post Result             │
              │      poster.Post()         │  → /completion + /status
              │14. Teardown                │
              │      worktree.Teardown()   │
              └────────────────────────────┘
                          │
                          ▼  *Result
                       caller

Public surface

Symbol Purpose
Runner Long-lived per-daemon orchestrator. Build once via New(opts).
Options DI seam: registry, worktree manager, poster, env composer, MCP builder, state store, prompt builder, http client, logger, timeouts. Required: Registry, WorktreeManager, Poster.
Registry agent.ProviderName → agent.Provider lookup. Built at daemon startup.
QueuedWork Embeds prompt.QueuedWork plus ResolvedProfile, Branch, WorkerID, AuthToken, PlatformURL. Wire shape mirrors the platform Redis session payload.
ResolvedProfile Provider, Model, Effort, CredentialID, ProviderConfig, plus the legacy Runner field for transitional wire shapes.
Result Embeds agent.Result plus SessionID, IssueIdentifier, StartedAt, FinishedAt, SteeringTriggered.

Failure modes

Verbatim from F.1.1 §5; classification owned by runner/failure.go.

FailureMode constant When it fires
worktree-provision runtime/worktree.Manager.Provision failed after retry budget.
prompt-render Prompt builder rejected the QueuedWork (empty issue context) or validation failed.
provider-resolve Registry has no entry for qw.ResolvedProfile.Provider.
spawn-failed Provider.Spawn returned error before events channel opened.
provider-error An ErrorEvent arrived before any terminal ResultEvent.
silent-exit The events channel closed without a terminal ResultEvent.
lost-ownership Heartbeat 3-strike threshold tripped (or worktree retry detected ownership loss).
timeout ctx cancelled before terminal event.
backstop-failed Stage 2 of tail recovery ran but could not push or open a PR.

Tail recovery

Two-stage post-completion recovery (F.0.1 §1):

  1. Stage 1 — steering. Fires when the provider supports SupportsMessageInjection or SupportsSessionResume AND the session ended successfully but without a PR URL. The runner injects a templated follow-up prompt asking the agent to commit/push/PR. Skipped via Options.SkipSteering.
  2. Stage 2 — backstop. Deterministic git workflow when steering didn't produce a PR (or the provider doesn't support steering). Skipped via Options.SkipBackstop for tests that don't have a real remote.

The backstop's path-exclude list is ported verbatim from agentfactory/packages/core/src/orchestrator/session-backstop.ts:57-95. The list lives at the top of backstop.go as Go data tables (excludeDirAnyDepth, excludeDirTopLevel, excludeExtensions, excludeBasenamePrefixes, excludePathPrefixes). When the legacy TS adds an entry, port it here in the same wave.

Backstop steps:

  1. git status --porcelain — snapshot uncommitted state.
  2. git add -A — stage everything.
  3. Enumerate staged files via git diff --cached --name-only; unstage anything matching the path-exclude tables.
  4. Safety cap: abort when staged count > backstopMaxFiles (200) — likely indicates a cache or node_modules slipped through.
  5. git commit -m "Backstop: <session-id> (<identifier>)" (skipped when nothing remains staged).
  6. git push -u origin <branch> (with --force-with-lease retry on non-fast-forward).
  7. gh pr create --title --body — return the URL on BackstopReport.PRURL.

Telemetry

Every event the provider emits is mirrored three ways:

  1. Local audit log — appended to <worktree>/.agent/events.jsonl as a JSON line decodable via agent.UnmarshalEvent.
  2. State snapshot<worktree>/.agent/state.json is updated on every InitEvent + on terminal events. Atomic tmpfile + rename via runtime/state.Store.
  3. Loggerslog.Default() (or Options.Logger) receives Debug/Info/Warn lines describing each step.

Per-event activity streaming to the platform (POST /api/sessions/<id>/activity) is left to F.5; today's runner only posts the terminal Result via result.Poster.Post.

Testing

# Unit + smoke (default)
go test -race ./runner/...

# Including the build-tagged integration test
go test -race -tags=runner_integration ./runner/...

The unit tests use the provider/stub package's BehaviorSucceedWithPR, BehaviorMidStreamError, BehaviorSilentFail, BehaviorHangThenTimeout, and BehaviorInjectTest modes to exercise every classification branch without depending on a real provider. The integration test (runner_integration build tag) drives a full Run() against a real bare-repo + httptest platform mock.

Tests skip when git is not on PATH so a barebones CI runner does not red-X the suite.

Extending

  • New provider — implement agent.Provider in provider/<name>/, register it in the registry at daemon startup. The runner's contract (capability-gated agent.Spec, normalized event channel) absorbs new providers without runner changes.
  • New work type — add a template to prompt/templates/; the runner reads qw.WorkType opaquely.
  • New failure mode — add a Failure* constant to runner/failure.go and update the classification site in runLoop.
  • New backstop exclusion — add to the data tables at the top of backstop.go; keep order/contents byte-identical with the legacy TS source.

What this package does NOT own

  • Provider-native event mapping → provider/{claude,codex,stub}/.
  • Worktree git ops → runtime/worktree.
  • Prompt rendering → prompt/.
  • Result HTTP calls → result/.
  • Heartbeat lock-refresh → runtime/heartbeat.
  • Session credential resolution → daemon (the runner consumes qw.AuthToken opaquely).

Documentation

Overview

Package runner orchestrates one agent session end-to-end.

It is the keystone of v0.5.0 — F.2.8 (daemon wire-up) calls Runner.Run for every claimed QueuedWork and the function does not return until the session is fully terminated (a result has been posted, the worktree torn down, and the heartbeat stopped).

The package wires the Wave 2/2b building blocks into the per-session main loop:

┌───────────────────────────────────────────────────────────────┐
│                      runner.Run(ctx, qw)                      │
│                                                               │
│  1.  Translate QueuedWork → agent.Spec (spec_translation.go)  │
│  2.  Resolve Provider via Registry        (registry.go)       │
│  3.  Provision worktree                   (runtime/worktree)  │
│  4.  Compose env (blocklist applied)      (runtime/env)       │
│  5.  Build MCP stdio config tmpfile       (runtime/mcp)       │
│  6.  Render system+user prompt            (prompt)            │
│  7.  Spawn provider                       (provider/...)      │
│  8.  Start heartbeat pulser               (runtime/heartbeat) │
│  9.  Stream events → state.json + post    (runtime/state)     │
│ 10.  Wait for terminal event (or cancel)                      │
│ 11.  Tail recovery: steering → backstop   (steering/backstop) │
│ 12.  Post result                          (result.Poster)     │
│ 13.  Teardown                                                 │
└───────────────────────────────────────────────────────────────┘

The package exports a small, public surface so rensei-tui can embed the runner without depending on private daemon plumbing:

  • Runner — long-lived per-daemon orchestrator built once via New, then Runner.Run called per session.
  • QueuedWork — input contract; mirrors the platform Redis payload plus the resolved-profile knob.
  • Result — terminal output; identical to agent.Result today but kept distinct for forward compatibility (so future runner wave hooks can extend it without touching the agent package).
  • Registry — provider resolution; configurable so test code can swap stubs in.

Failure modes follow F.1.1 §5 verbatim:

  • Worktree provisioning retries 3 times with 15s delay. Lost ownership during retry short-circuits the run.
  • Heartbeat 3-strike trips runtime/heartbeat.ErrLostOwnership; the runner cancels the provider via Handle.Stop and records FailureMode "lost-ownership".
  • Provider Spawn errors map to FailureMode "spawn-failed".
  • ErrorEvents on the stream before any terminal ResultEvent map to FailureMode "provider-error".
  • Steering failure falls through to backstop; backstop failure records its diagnostics on Result.BackstopReport.

Two-stage tail recovery on agent completion (F.0.1 §1):

  • Stage 1 (steering): when the provider supports message injection or session resume and the session ended without a PR, the runner sends a per-provider templated steering prompt asking the agent to commit/push/PR.
  • Stage 2 (backstop): when steering did not produce a PR (or the provider does not support steering), the runner runs a deterministic git workflow (`git add -A` with the path-exclude list, `git commit`, `git push`, `gh pr create`).

The path-exclude list in [backstop.go] is ported verbatim from the legacy TS at agentfactory/packages/core/src/orchestrator/session-backstop.ts:57-95.

Telemetry

Every Event is mirrored three ways:

  1. Appended to <worktree>/.agent/events.jsonl (audit trail).
  2. State-store snapshot updated for crash recovery.
  3. Logger.Debug for operator dashboards.

Posting per-event activity to the platform is left to F.5; today's runner only posts the terminal Result (via result.Poster.Post).

Package runner provider_view.go — adapter that exposes the in-process AgentRuntime registry as the read-only daemon.ProviderRegistry view consumed by the /api/daemon/providers* HTTP handler. Wave 9 / A1.

The adapter lives in the runner package (not daemon) so daemon stays free of a runner import — daemon.ProviderRegistry is the interface, runner.NewProviderView builds the concrete view from a *Registry.

Index

Constants

View Source
const (
	// FailureWorktreeProvision indicates the worktree manager could
	// not provision a worktree after MaxSpawnRetries attempts. Often
	// preceded by a transient git-conflict error or a lost-ownership
	// short-circuit (see FailureLostOwnership).
	FailureWorktreeProvision = "worktree-provision"

	// FailurePromptRender indicates the prompt builder rejected the
	// QueuedWork (typically because the caller passed empty issue
	// context). Permanent — retrying without changing the input
	// will fail the same way.
	FailurePromptRender = "prompt-render"

	// FailureProviderResolve indicates the runner could not resolve
	// the requested provider name in its registry. Permanent. Often
	// indicates a misconfigured ResolvedProfile.Provider.
	FailureProviderResolve = "provider-resolve"

	// FailureSpawn indicates Provider.Spawn returned an error before
	// the events channel opened (e.g. CLI binary missing, app-server
	// unreachable). Wraps agent.ErrSpawnFailed.
	FailureSpawn = "spawn-failed"

	// FailureProviderError indicates the provider emitted an
	// ErrorEvent before any terminal ResultEvent. The error message
	// surfaces via Result.Error.
	FailureProviderError = "provider-error"

	// FailureSilentExit indicates the provider closed the events
	// channel without emitting either a ResultEvent or an ErrorEvent.
	// The runner synthesizes a failure record for these cases.
	FailureSilentExit = "silent-exit"

	// FailureLostOwnership indicates the per-session heartbeat tripped
	// its 3-strike threshold mid-session (or the worktree manager
	// detected ownership loss between retries). The runner cancels
	// the provider via Handle.Stop and tears down without backstop.
	FailureLostOwnership = "lost-ownership"

	// FailureTimeout indicates ctx was cancelled before the session
	// terminated. Surfaces when the daemon's per-session deadline
	// expires.
	FailureTimeout = "timeout"

	// FailureBackstop indicates the deterministic git backstop ran
	// but failed to push or open a PR; diagnostics live on
	// Result.BackstopReport.Diagnostics.
	FailureBackstop = "backstop-failed"

	// FailureKitProvision indicates a kit toolchain-install command or a
	// post_acquire hook exited non-zero before the agent spawned (Seam 2:
	// "no partial toolchain"). The session aborts; the agent never starts.
	// The failing command + exit code surface via Result.Error.
	FailureKitProvision = "kit-provision"

	// FailureAgentBlocked indicates the agent terminated normally but
	// DELIBERATELY declined to do the work — it judged the spec ambiguous,
	// the preconditions unmet, or the task outside its remit, and said so
	// instead of producing code. This is distinct from a crash
	// (FailureProviderError), a silent exit (FailureSilentExit), or a
	// budget/timeout cut-off: the agent made a reasoned decision to stop.
	//
	// The signal is structural — the agent emits an explicit decline
	// marker ("WORK_RESULT:blocked" or "AGENT_BLOCKED: <reason>") which
	// the runner scans for. A blocked result MUST NOT trigger the empty-
	// branch backstop or steering (there is nothing to recover), and the
	// platform side should surface it as a needs-clarification outcome
	// rather than re-dispatching the identical context — re-dispatch only
	// re-runs the agent into the same wall while re-billing the full
	// context-assembly prefix.
	FailureAgentBlocked = "agent-blocked"
)

Failure-mode classification constants for [Result.FailureMode].

The values are stable wire strings so platform-side dashboards and Linear comments can dispatch off them without scraping log lines. Add new values at the bottom; never repurpose an existing one.

View Source
const (
	// DefaultMaxSessionDuration is the upper-bound timeout the runner
	// applies to ctx when [Options.MaxSessionDuration] is zero. Two
	// hours matches the legacy TS MAX_SESSION_DURATION constant.
	DefaultMaxSessionDuration = 2 * time.Hour

	// DefaultEventBufferSize is the buffered-channel size used to
	// decouple event mirroring from the provider goroutine. A small
	// number keeps memory bounded; spikes block the provider for at
	// most one event before backpressure kicks in.
	DefaultEventBufferSize = 64
)

Default values for Options. Exposed for tests and operator debugging; production daemons override via Options.

View Source
const (
	WorkTypeResearch                 = "research"
	WorkTypeBacklogCreation          = "backlog-creation"
	WorkTypeBacklogGroomer           = "backlog-groomer"
	WorkTypeDevelopmentStr           = "development"
	WorkTypeInflight                 = "inflight"
	WorkTypeQAStr                    = "qa"
	WorkTypeAcceptance               = "acceptance"
	WorkTypeRefinement               = "refinement"
	WorkTypeRefinementCoordination   = "refinement-coordination"
	WorkTypeMerge                    = "merge"
	WorkTypeSecurity                 = "security"
	WorkTypeImprovementLoop          = "improvement-loop"
	WorkTypeOutcomeAuditor           = "outcome-auditor"
	WorkTypeGAReadiness              = "ga-readiness"
	WorkTypeDocumentationSteward     = "documentation-steward"
	WorkTypeOperationalScannerVercel = "operational-scanner-vercel"
	WorkTypeOperationalScannerAudit  = "operational-scanner-audit"
	WorkTypeOperationalScannerCI     = "operational-scanner-ci"
	WorkTypeCoordination             = "coordination"
	WorkTypeInflightCoordination     = "inflight-coordination"
)

Known agent work-type names. Verbatim mirror of AgentWorkType from agentfactory/packages/core/src/orchestrator/work-types.ts so any new work type the platform adds shows up here as a missing entry on `make test` (TestWorkTypeStatusMappings_Exhaustive).

Stable string constants; never repurpose an existing value.

View Source
const FailureBudgetExceeded = "budget-exceeded"

FailureBudgetExceeded classifies a session that hit a stage-budget cap (REN-1485 / REN-1487 Phase 2 acceptance criterion #4). The per-cap details live on Result.BudgetReport.

Variables

AllWorkTypes lists every recognised work type. Used by exhaustive mapping tests (every entry must appear in workTypeCompleteStatus and workTypeFailStatus, even when the value is empty). Order matches the declaration above; tests do not depend on order.

Functions

func IsBudgetExceeded

func IsBudgetExceeded(err error) bool

IsBudgetExceeded reports whether err wraps a *BudgetExceededError. Convenience helper for the runner's classification fork.

func RequiresWorkResult

func RequiresWorkResult(workType string) bool

RequiresWorkResult reports whether the contract for this work type includes FieldWorkResult as a required field. Drives the diagnostic-comment branch in the post-session block when the agent exits without emitting a WORK_RESULT marker.

Types

type BudgetCap

type BudgetCap string

BudgetCap names which cap was breached. Surfaces in Result.BudgetReport.CapBreached so dashboards can group breaches by kind.

const (
	CapDuration  BudgetCap = "max-duration-seconds"
	CapSubAgents BudgetCap = "max-sub-agents"
	CapTokens    BudgetCap = "max-tokens"
)

Cap kind constants. Stable wire values — never repurpose; add new caps at the bottom.

type BudgetEnforcer

type BudgetEnforcer struct {
	// contains filtered or unexported fields
}

BudgetEnforcer is a per-session counter + cap checker. Constructed once per Run with the session's StageBudget; mutated only via Track* methods (concurrency-safe via atomic counters + a sync.Mutex for the breach record).

Wall-clock enforcement is delegated to context.WithTimeout — the constructor returns a derived ctx that fires when MaxDurationSeconds elapses. Token + sub-agent enforcement is observation-driven: every agent.Event the runner streams flows through ObserveEvent, which returns a non-nil error when the cap is breached. The runner sees the error, classifies the failure as budget-exceeded, and stops the provider.

When the dispatch carries no StageBudget (legacy path) New returns a no-op Enforcer whose Track* methods always return nil — the runner can call them unconditionally.

func NewBudgetEnforcer

func NewBudgetEnforcer(b *prompt.StageBudget, now time.Time) *BudgetEnforcer

NewBudgetEnforcer constructs an enforcer for the given budget. A nil budget produces a disabled enforcer (no caps, no enforcement) so callers can use one code path for legacy + stage dispatch.

func (*BudgetEnforcer) CheckDuration

func (e *BudgetEnforcer) CheckDuration(now time.Time) *BudgetExceededError

CheckDuration returns a non-nil *BudgetExceededError when the wall-clock cap has been breached. The runner calls this from the post-loop classification path so a context-deadline-exceeded surfaces as the canonical budget breach reason instead of FailureTimeout. Returns nil when no cap is configured or the cap has not yet tripped.

func (*BudgetEnforcer) Enabled

func (e *BudgetEnforcer) Enabled() bool

Enabled reports whether the enforcer has any caps to enforce.

func (*BudgetEnforcer) ObserveEvent

func (e *BudgetEnforcer) ObserveEvent(ev agent.Event) *BudgetExceededError

ObserveEvent updates the running counters from an agent.Event and returns a non-nil *BudgetExceededError when the event tripped a cap. Callers should treat the error as a clean cancellation: stop the provider, classify the failure, write WORK_RESULT.

The enforcer continues to track counters after a breach — the caller may receive the same error again on subsequent events. This is intentional: the runner's first response is to cancel the stream context, but events buffered by the provider may still flow until the channel closes.

func (*BudgetEnforcer) Report

func (e *BudgetEnforcer) Report(now time.Time) *BudgetReport

Report returns the per-session enforcement record. Safe to call at any point; values reflect the latest observations + breach state. Always returns a non-nil report — the runner attaches it to Result.BudgetReport unconditionally so dashboards can show "no budget enforced" sessions distinctly from "budget OK" ones.

func (*BudgetEnforcer) WithDurationCap

func (e *BudgetEnforcer) WithDurationCap(parent context.Context) (context.Context, context.CancelFunc)

WithDurationCap returns a derived context that automatically cancels when the wall-clock budget elapses. Returns the input ctx unchanged when no duration cap is configured. The returned cancel is always non-nil and safe to defer.

type BudgetExceededError

type BudgetExceededError struct {
	Cap    BudgetCap
	Detail string
}

BudgetExceededError is returned by ObserveEvent / CheckDuration when a cap has been tripped. The runner classifies the failure as FailureBudgetExceeded and stops the provider cleanly.

func (*BudgetExceededError) Error

func (e *BudgetExceededError) Error() string

Error satisfies the error interface.

type BudgetReport

type BudgetReport struct {
	// Enforced is true when the runner had a non-nil StageBudget to
	// enforce. False when the session was dispatched with no budget
	// (legacy path) — the report is then a no-op observation record.
	Enforced bool `json:"enforced"`

	// Limits captures the configured caps the runner was enforcing.
	// All-zero means "no caps set, proceed unbounded."
	Limits prompt.StageBudget `json:"limits"`

	// ObservedSubAgents counts the Task tool invocations seen across
	// the session.
	ObservedSubAgents int `json:"observedSubAgents"`

	// ObservedTokens is the cumulative input+output token count
	// observed across all turns. Sourced from per-turn ResultEvent.Cost
	// and the final terminal CostData.
	ObservedTokens int64 `json:"observedTokens"`

	// ObservedDurationSeconds is the wall-clock the session ran for at
	// terminal time (or at the breach point).
	ObservedDurationSeconds int `json:"observedDurationSeconds"`

	// CapBreached names which cap tripped. Empty when the session
	// completed within budget.
	CapBreached BudgetCap `json:"capBreached,omitempty"`

	// BreachDetail is the human-readable "<cap> exceeded: observed=X,
	// limit=Y" string. Empty when no breach.
	BreachDetail string `json:"breachDetail,omitempty"`
}

BudgetReport is the per-session enforcement report. Always present when the session was dispatched with a non-nil StageBudget; nil otherwise (legacy `agent.dispatch_to_queue` work has no budget to report on). Surfaced on Result.BudgetReport so the platform's WORK_RESULT consumer can render the breach reason without scraping log lines.

type CompletionContract

type CompletionContract struct {
	WorkType string
	Required []CompletionField
	Optional []CompletionField
}

CompletionContract enumerates the required + optional fields a session of the given work type must produce.

func GetCompletionContract

func GetCompletionContract(workType string) (CompletionContract, bool)

GetCompletionContract returns the completion contract for a work type. Returns the contract and true on hit; returns the zero CompletionContract and false for unknown work types (caller treats as no contract — best-effort completion).

type CompletionField

type CompletionField struct {
	Type            CompletionFieldType
	Label           string
	BackstopCapable bool
}

CompletionField is a single required or optional field in a completion contract. BackstopCapable=true means the runner can fill the field deterministically post-session (e.g. push a branch). Fields that require agent judgement (work_result) are NOT backstop-capable.

type CompletionFieldType

type CompletionFieldType string

CompletionFieldType is the discriminator for the field set every contract enumerates. Verbatim mirror of CompletionFieldType from the legacy TS file.

const (
	FieldPRURL              CompletionFieldType = "pr_url"
	FieldBranchPushed       CompletionFieldType = "branch_pushed"
	FieldCommitsPresent     CompletionFieldType = "commits_present"
	FieldWorkResult         CompletionFieldType = "work_result"
	FieldIssueUpdated       CompletionFieldType = "issue_updated"
	FieldCommentPosted      CompletionFieldType = "comment_posted"
	FieldSubIssuesCreated   CompletionFieldType = "sub_issues_created"
	FieldPRMerged           CompletionFieldType = "pr_merged"
	FieldPRMergedOrEnqueued CompletionFieldType = "pr_merged_or_enqueued"
)

Field-type constants. Stable wire strings — never repurpose an existing one.

type CredentialProvider

type CredentialProvider func(context.Context) (RuntimeCredentials, error)

CredentialProvider returns the freshest worker runtime credentials available to the caller. Implementations should be cheap and concurrency-safe.

type KitDetector added in v0.10.0

type KitDetector func(repoRoot, targetOS string) ([]kit.ManifestView, error)

KitDetector resolves the ordered kit manifests that apply to a worktree at repoRoot for targetOS (foundation → framework → project). Returns an empty slice when no kit applies. Implemented by KitRegistry.DetectForRepo.

type LinearStatusTransition

type LinearStatusTransition struct {
	// WorkType is the agent work type the decision was made for.
	WorkType string `json:"workType,omitempty"`

	// WorkResult is the parsed marker driving the transition
	// ("passed" | "failed" | "unknown" | "").
	WorkResult string `json:"workResult,omitempty"`

	// TargetStatus is the Linear workflow-state name the runner
	// attempted to transition to. Empty when no transition was
	// attempted.
	TargetStatus string `json:"targetStatus,omitempty"`

	// Attempted is true when the runner called UpdateIssueStatus.
	Attempted bool `json:"attempted,omitempty"`

	// Succeeded is true when UpdateIssueStatus returned nil.
	Succeeded bool `json:"succeeded,omitempty"`

	// Reason is a short identifier from PostSessionDecision.Reason
	// ("passed", "failed", "unknown", "completed-non-sensitive",
	// "deferred-merge-queue", "no-mapping", ...).
	Reason string `json:"reason,omitempty"`

	// Error is the human-readable error message when the transition
	// failed. Empty on success.
	Error string `json:"error,omitempty"`

	// DiagnosticPosted is true when the runner posted the
	// "missing WORK_RESULT" diagnostic comment to Linear (i.e. the
	// Reason was "unknown" and the comment post succeeded).
	DiagnosticPosted bool `json:"diagnosticPosted,omitempty"`
}

LinearStatusTransition records the runner's post-session attempt to transition the Linear issue's workflow state. Built from resolveTargetStatus and the UpdateIssueStatus call result.

type MCPConfigPath

type MCPConfigPath struct {
	Path    string
	Cleanup func()
}

MCPConfigPath wraps the runtime/mcp.Builder output so the loop has a single path-and-cleanup pair to thread through Spec construction + teardown. The cleanup closure is no-op when no MCP servers were requested, matching mcp.Builder.Build semantics.

type Options

type Options struct {
	// Registry is the provider registry the runner consults on each
	// Run to resolve QueuedWork.ResolvedProfile.Provider. Required.
	Registry *Registry

	// WorktreeManager owns clone/teardown of per-session worktrees.
	// Required.
	WorktreeManager *worktree.Manager

	// Poster posts the terminal Result back to the platform.
	// Required.
	Poster *result.Poster

	// CredentialProvider supplies the freshest platform worker credentials
	// for long-running child sessions. Heartbeats call it before every tick so
	// they can pick up daemon-side runtime-token refreshes.
	CredentialProvider CredentialProvider

	// EnvComposer builds the agent subprocess env. Defaults to
	// env.NewComposer().
	EnvComposer *env.Composer

	// MCPBuilder builds the per-session MCP stdio config tmpfile.
	// Defaults to mcp.NewBuilder().
	MCPBuilder *mcp.Builder

	// StateStore writes .agent/state.json snapshots. Defaults to
	// state.NewStore().
	StateStore *state.Store

	// PromptBuilder renders the (system, user) prompt pair.
	// Defaults to &prompt.Builder{}.
	PromptBuilder *prompt.Builder

	// HTTPClient is forwarded to the heartbeat pulser for
	// /api/sessions/<id>/lock-refresh calls. Defaults to a 30s-timeout
	// http.Client.
	HTTPClient *http.Client

	// Logger receives Debug/Info/Warn lines describing each step.
	// Defaults to slog.Default().
	Logger *slog.Logger

	// Now is injected for deterministic tests. Defaults to time.Now.
	Now func() time.Time

	// MaxSessionDuration is the per-Run upper-bound on ctx. Zero
	// falls back to DefaultMaxSessionDuration. Negative disables the
	// runner-side timeout (caller is responsible for ctx expiry).
	MaxSessionDuration time.Duration

	// PreserveWorktreeOnFailure keeps the worktree on disk after a
	// failed Run for debugging. Defaults to true in v0.5.0 per F.1.1
	// §10 Q7 — flip to false after smoke-harness confidence is high.
	PreserveWorktreeOnFailure bool

	// PreserveWorktreeAlways keeps the worktree on disk after every
	// Run regardless of outcome. Used by tests that need to inspect
	// .agent/events.jsonl or state.json post-Run, and by debug
	// builds that want zero auto-cleanup. Defaults to false.
	PreserveWorktreeAlways bool

	// SkipBackstop disables the deterministic backstop entirely.
	// Used by tests that don't have a real git worktree.
	SkipBackstop bool

	// SkipSteering disables the steering stage of tail recovery.
	// Used by tests that need a deterministic recovery flow.
	SkipSteering bool

	// SkipPostSession disables the post-session Linear state-transition
	// block (REN-1467 / loop.go step 11b). Tests that don't have a
	// platform mock with /api/issue-tracker-proxy support, or that
	// want to assert on the pre-transition Result envelope, set this
	// to skip the block entirely. Production daemons leave it false.
	SkipPostSession bool

	// HeartbeatInterval overrides the per-session heartbeat cadence.
	// Zero falls back to runtime/heartbeat.DefaultInterval.
	HeartbeatInterval time.Duration

	// KitSkillSources is the optional slice of Kit skill contributions to
	// inject into each dispatched agent's system prompt and tool surface.
	// Populated by the daemon at runner construction time from the active
	// Kits in the KitRegistry (via internal/kit.LoadSkills). When nil or
	// empty no Kit skill injection occurs — the runner behaves as before
	// this feature was added (additive/cardinal rule 1 compliant).
	//
	// Each element describes one active Kit's skill file list (paths
	// relative to the Kit's manifest directory) and priority for
	// ordering the merged system-prompt append block.
	KitSkillSources []kit.KitSkillSource

	// KitDetector resolves the kit manifests that apply to a provisioned
	// worktree, ordered foundation → framework → project. The daemon
	// wires this to KitRegistry.DetectForRepo over its active kits; nil
	// disables kit toolchain provisioning entirely (the runner behaves
	// exactly as before K1). Computed inside the runner because the runner
	// owns the worktree path (K1.4); mirrors how KitSkillSources is
	// daemon-populated.
	KitDetector KitDetector

	// KitTargetOS overrides the OS the kit toolchain demand is composed
	// for. Empty falls back to the host OS (kit.MustResolveOS). The daemon
	// sets this to the SANDBOX OS ("linux") for cloud-targeted sessions so
	// install scripts match the sandbox, not the dispatching host (OD-2).
	KitTargetOS string
}

Options carries the long-lived configuration a Runner needs.

Required fields: Registry, WorktreeManager, Poster. The remaining fields have sensible defaults so simple consumers can call New(Options{Registry: r, WorktreeManager: m, Poster: p}) without having to plumb every collaborator.

type PostSessionDecision

type PostSessionDecision struct {
	WorkType         string
	WorkResult       string // "passed" | "failed" | "unknown" | ""
	TargetStatus     string // Linear workflow-state name, e.g. "Finished"
	ShouldTransition bool
	PostDiagnostic   bool
	Deferred         bool
	Reason           string
}

PostSessionDecision is the typed outcome of resolveTargetStatus. Callers use it to drive the actual side effects:

  • TargetStatus non-empty + ShouldTransition true: call UpdateIssueStatus(issueID, TargetStatus).
  • PostDiagnostic true: post the "missing WORK_RESULT" comment to Linear (do NOT transition).
  • Deferred true: log "deferred to merge queue" and skip transition.

Reason is a free-form short identifier surfaced in logs ("passed", "failed", "unknown", "agent-failed", "completed-non-sensitive", "deferred-merge-queue", "no-mapping").

type ProviderNotRegisteredError

type ProviderNotRegisteredError struct {
	// RequestedID is the ProviderID from the ResolvedModelProfile.
	RequestedID string
	// Registered is the snapshot of provider names at call time.
	Registered []string
}

ProviderNotRegisteredError is returned by SelectProvider when the requested provider family is not registered on this host. It is a structured error (not a plain string) so daemon dispatch code can log the exact requested + available set in one log line.

func (*ProviderNotRegisteredError) Error

type ProviderView

type ProviderView struct {
	// contains filtered or unexported fields
}

ProviderView wraps a *Registry and satisfies daemon.ProviderRegistry. Construct via NewProviderView. Read-only and safe for concurrent use.

func NewProviderView

func NewProviderView(reg *Registry) *ProviderView

NewProviderView returns a ProviderView backed by reg. Pass the result to daemon.Options.ProviderRegistry to expose the runner's registered AgentRuntime providers via the daemon's HTTP control API.

func (*ProviderView) Capabilities

func (v *ProviderView) Capabilities(name string) (map[string]any, bool)

Capabilities returns the typed capability struct serialised to a flat map[string]any for the named provider, or (nil, false) when the provider is not registered. The map shape matches the JSON encoding of agent.Capabilities so the wire shape on /api/daemon/providers satisfies the contract in afclient/provider_types.go.

func (*ProviderView) Names

func (v *ProviderView) Names() []string

Names returns the sorted list of registered provider names as plain strings (the daemon.ProviderRegistry contract). The underlying Registry.Names() returns []agent.ProviderName which is just a typed alias; we widen the wire shape here.

type QueuedWork

type QueuedWork struct {
	prompt.QueuedWork

	// ResolvedProfile carries the model-profile knobs the platform
	// resolved before queueing this work. The runner reads
	// ResolvedProfile.Provider to select which provider implementation
	// runs the session.
	ResolvedProfile ResolvedProfile `json:"resolvedProfile,omitempty"`

	// Branch is the working branch name the runner should use when
	// provisioning the worktree. Empty falls back to "agent/<sessionID>".
	Branch string `json:"branch,omitempty"`

	// WorkerID is the daemon worker that claimed this session. Used
	// for ownership probes inside the worktree retry loop and as the
	// {workerId} in the heartbeat refresh body. Required.
	WorkerID string `json:"workerId,omitempty"`

	// AuthToken is the worker's bearer token used for platform API
	// calls (heartbeat, result post). The daemon resolves this from
	// the registration store; the runner just forwards it.
	AuthToken string `json:"-"`

	// PlatformURL is the base URL of the platform (e.g.
	// "https://platform.example.com" or "http://127.0.0.1:3010"). The runner
	// forwards this to result.Poster + heartbeat.Pulser. Required.
	PlatformURL string `json:"-"`
}

QueuedWork is the runner's input contract — the per-session payload the daemon hands to Runner.Run. It embeds the prompt package's prompt.QueuedWork (which carries the issue/identifier/context the prompt builder consumes) and adds the runner-specific knobs the orchestrator needs (resolved profile, branch, worker id).

Wire shape: matches the platform Redis session payload at "agent:session:<id>" verbatim. F.1.1 §1 + the live payload observed during F.2.7 (REN2-1) drive the field set.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry resolves agent.ProviderName values to their corresponding agent.Provider instances. The runner builds one Registry at daemon startup (per F.2.8 wire-up) and consults it on every Run.

The zero value of Registry is unusable — callers must build one via NewRegistry which seeds the map.

Concurrency: Registry is safe for concurrent reads. Registration is only safe before the runner starts dispatching Runs; treat it as build-once, read-many.

func NewRegistry

func NewRegistry() *Registry

NewRegistry constructs an empty Registry. Use Registry.Register to add providers.

func (*Registry) Names

func (r *Registry) Names() []agent.ProviderName

Names returns the sorted list of registered provider names. Useful for daemon-startup logging and the `donmai agent providers` admin command.

func (*Registry) Register

func (r *Registry) Register(p agent.Provider) error

Register adds p under its declared Name. Calling Register with a different instance under an existing name overwrites the earlier entry — daemon startup decides whether duplicate registration is fatal.

Returns an error when p is nil or its Name is empty (a programmer error: every Provider implementation must declare a name).

func (*Registry) Resolve

func (r *Registry) Resolve(name agent.ProviderName) (agent.Provider, error)

Resolve returns the registered Provider for name, or agent.ErrNoProvider when no provider is registered. The error is wrapped with the requested name so callers can log forensics.

func (*Registry) SelectProvider

func (r *Registry) SelectProvider(profile ResolvedModelProfile) (agent.Provider, error)

SelectProvider resolves a provider implementation from the registry using the fully-rendered ResolvedModelProfile. It is the preferred dispatch entry point when the platform has already resolved the profile end-to-end; callers that only have a raw ProviderName should use Registry.Resolve directly.

Lookup order:

  1. profile.ProviderID (exact match against registered provider names)
  2. If unregistered, return a descriptive *ProviderNotRegisteredError

An empty ProviderID falls back to agent.ProviderClaude for backwards compatibility with dispatches that arrive before the platform ships the enriched profile.

Returns (nil, *ProviderNotRegisteredError) when the requested provider is not registered on this host. The error carries the ProviderID and the names currently registered so the caller can log a useful diagnostic.

func (*Registry) Shutdown

func (r *Registry) Shutdown(ctx context.Context) error

Shutdown calls Provider.Shutdown on every registered provider and returns the joined error if any failed. Daemon drain calls this once on graceful exit so long-lived child processes (codex app-server) terminate cleanly.

A nil error means every provider's Shutdown returned nil. When multiple providers fail, the returned error is errors.Join'd so callers see all failures.

type ResolvedModelProfile

type ResolvedModelProfile struct {
	// ID is the model_profile row UUID from the platform's model_profiles
	// table (e.g. "mp_01jt5..."). Used for audit logging and for
	// cross-referencing the platform's profile management API.
	ID string `json:"id"`

	// ProviderID is the canonical provider family identifier
	// (e.g. "claude", "codex", "gemini", "ollama"). Matches the
	// agent.ProviderName enum; the daemon calls SelectProvider(profile)
	// which converts this to agent.ProviderName internally.
	ProviderID string `json:"providerId"`

	// Model is the model variant within the provider family
	// (e.g. "claude-opus-4-7", "gpt-4o-2025-04"). Empty falls back to
	// the provider's built-in default model.
	Model string `json:"model"`

	// Mode is the reasoning-effort/speed tier string the platform
	// resolved (e.g. "xhigh", "high", "medium", "low"). Maps onto
	// agent.EffortLevel; empty falls back to the provider default.
	Mode string `json:"mode,omitempty"`

	// Context is the context-window size in tokens the platform requires
	// for this dispatch (e.g. 1_000_000 for claude-3-7-sonnet-1m).
	// Zero means "use the model default".
	Context int `json:"context,omitempty"`

	// MaxOutputTokens is the per-response output-token budget the
	// platform resolver picked from the model catalog row. Zero means
	// "use the model default".
	MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}

ResolvedModelProfile is the fully-rendered provider/model specification the platform passes with each dispatch (ADR-2026-05-12-worktype-and-model- profile-routing). The Go daemon receives this from the platform as part of the poll response and uses it to select the correct provider implementation instead of falling back to the local-config default.

JSON tags follow the platform-side camelCase wire shape so the struct can be embedded directly in HTTP request/response bodies.

Relationship to ResolvedProfile: ResolvedModelProfile is the richer, platform-resolved shape carrying ID + context window + output-token caps. ResolvedProfile (runner/types.go) is the legacy shape used by QueuedWork. When a dispatch includes a ResolvedModelProfile it supersedes the ResolvedProfile fields; detailToQueuedWork (afcli/agent_run.go) bridges the two shapes.

func (ResolvedModelProfile) ToResolvedProfile

func (p ResolvedModelProfile) ToResolvedProfile() ResolvedProfile

ToResolvedProfile converts a ResolvedModelProfile into the legacy runner.ResolvedProfile shape so callers can merge it into a QueuedWork without knowing both types. Fields not present in the legacy shape (Context, MaxOutputTokens) are injected via ProviderConfig so providers that honor extended knobs can consume them.

type ResolvedProfile

type ResolvedProfile struct {
	// Provider names the provider family that should run the session
	// (claude/codex/stub for v0.5.0). When empty the runner falls
	// back to the legacy `Runner` field, then to agent.ProviderClaude.
	Provider agent.ProviderName `json:"provider,omitempty"`

	// Runner is the legacy field name some platform deployments use
	// for the same value. The runner reads Provider first and falls
	// back to Runner so an in-flight wire-shape transition does not
	// break dispatch.
	Runner string `json:"runner,omitempty"`

	// Model identifies the model variant within the provider family
	// (e.g. "claude-sonnet-4-5"). Empty falls back to the provider
	// default.
	Model string `json:"model,omitempty"`

	// Effort is the normalized reasoning-effort tier the provider
	// should pass through to its native knob. Honored by providers
	// with SupportsReasoningEffort=true.
	Effort agent.EffortLevel `json:"effort,omitempty"`

	// CredentialID identifies the credential entry the daemon should
	// resolve into provider-native auth (e.g. ANTHROPIC_API_KEY) and
	// inject via Spec.Env.
	CredentialID string `json:"credentialId,omitempty"`

	// ProviderConfig carries provider-specific knobs from the matched
	// model profile. Forwarded into agent.Spec.ProviderConfig.
	ProviderConfig map[string]any `json:"providerConfig,omitempty"`
}

ResolvedProfile names the profile knobs the platform resolved for this session. Mirrors F.1.1 §4 ResolvedProfile shape.

JSON tags follow the platform-side camelCase wire shape (consumed by the daemon poll handler).

type Result

type Result struct {
	agent.Result

	// SessionID is the platform-side session UUID this result
	// belongs to. Echoed for caller convenience.
	SessionID string `json:"sessionId,omitempty"`

	// IssueIdentifier is the human-readable issue identifier (e.g.
	// "REN-1459"). Echoed for log correlation.
	IssueIdentifier string `json:"issueIdentifier,omitempty"`

	// StartedAt is the unix-ms timestamp when [Runner.Run] entered
	// step 1 of the loop.
	StartedAt int64 `json:"startedAt,omitempty"`

	// FinishedAt is the unix-ms timestamp when [Runner.Run] returned
	// the Result (after teardown).
	FinishedAt int64 `json:"finishedAt,omitempty"`

	// SteeringTriggered reports whether tail-recovery stage 1 fired.
	SteeringTriggered bool `json:"steeringTriggered,omitempty"`

	// PostSessionWarnings collects non-fatal warnings raised by the
	// post-session block (REN-1467) — e.g. "Linear updateIssueStatus
	// failed: …" or "diagnostic comment post failed: …". These are
	// strictly observability — they do NOT change the session's
	// terminal Status. Surface them in operator dashboards so a
	// silently-failed transition is visible.
	PostSessionWarnings []string `json:"postSessionWarnings,omitempty"`

	// LinearStatusTransition records the result of the post-session
	// Linear status-update attempt (REN-1467). Empty when no
	// transition was attempted (non-result-sensitive type with no
	// mapping, or marker was unknown). Non-nil even on failure so the
	// caller can correlate dashboard signals to runner logs.
	LinearStatusTransition *LinearStatusTransition `json:"linearStatusTransition,omitempty"`

	// BudgetReport captures the per-stage budget enforcement record
	// (REN-1485 / REN-1487 Phase 2 acceptance criterion #4). Non-nil
	// for every Run; the .Enforced flag distinguishes
	// stage-dispatched work (caps configured) from legacy work
	// (caps absent). When a cap was breached .CapBreached + .BreachDetail
	// surface the reason; the session's Status is "failed" with
	// FailureMode=FailureBudgetExceeded.
	BudgetReport *BudgetReport `json:"budgetReport,omitempty"`
}

Result is the terminal output of a Runner.Run call.

Today it is a thin alias around agent.Result with the addition of runner-internal fields (StartedAt, FinishedAt) and a direct field echo of the platform-relevant identifiers so callers do not have to thread the QueuedWork through their result handler. Forward-compat: new runner-wave hooks can extend Result without touching the agent/types.go contract.

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner is the long-lived per-daemon orchestrator. Build one via New at daemon startup and call Runner.Run for every claimed QueuedWork.

Runner is safe for concurrent use across sessions: every method holds only per-Run state via locals; collaborators (Registry, WorktreeManager, etc.) are documented as concurrency-safe by their own packages.

func New

func New(opts Options) (*Runner, error)

New constructs a Runner from opts. Returns an error when any required collaborator is missing.

func (*Runner) Run

func (r *Runner) Run(ctx context.Context, qw QueuedWork) (*Result, error)

Run orchestrates one session end-to-end. It does not return until the session has reached a terminal state (success, failure, or cancellation) and the result has been posted.

The returned Result is always non-nil — on a fatal error the Result.Status is "failed" and Result.FailureMode classifies the reason. Callers should log err and inspect Result for the platform-relevant fields (PR URL, cost, etc).

Cancellation: ctx is honored at every step. A cancelled ctx short-circuits the loop with FailureMode "timeout"; the runner still attempts result.Post + teardown so platform state stays consistent.

type RuntimeCredentials

type RuntimeCredentials struct {
	WorkerID  string
	AuthToken string
}

RuntimeCredentials are the bearer-token credentials needed for session heartbeats and status posts.

type SpecInputs

type SpecInputs struct {
	// Cwd is the worktree path the worktree manager just provisioned.
	Cwd string

	// Prompt is the rendered user prompt (from prompt.Builder.Build).
	Prompt string

	// SystemPromptAppend is the rendered system-append block from the
	// prompt builder; threaded into Spec.SystemPromptAppend for
	// providers that consume it.
	SystemPromptAppend string

	// InitialContext is large/volatile session context (e.g. recalled
	// agent memory) the runner routes through Spec.InitialContext so it
	// rides the first turn's input rather than the re-sent system-prompt
	// prefix. Empty unless the resolved provider declares
	// Capabilities.SupportsTurnInputContext.
	InitialContext string

	// MCPServers is the list of MCP stdio configs the runtime/mcp
	// builder produced. Empty when no plugins are enabled.
	MCPServers []agent.MCPServerConfig

	// Env is the merged session env (output of runtime/env.Compose,
	// already rebuilt as a map for Spec.Env).
	Env map[string]string

	// Autonomous mirrors the daemon's session-mode flag — true for
	// agent sessions invoked from the work queue.
	Autonomous bool
}

SpecInputs are the per-session inputs the [translateSpec] helper merges with the QueuedWork to produce an agent.Spec. Splitting these out keeps spec_translation pure (no I/O, no platform calls) and makes the loop easy to test in isolation.

Directories

Path Synopsis
Package access is the pure, DB-free OSS enforcement mirror of the platform's narrow-only model-access semantics (platform/src/lib/billing/access-policy.ts).
Package access is the pure, DB-free OSS enforcement mirror of the platform's narrow-only model-access semantics (platform/src/lib/billing/access-policy.ts).

Jump to

Keyboard shortcuts

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