runner

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package runner owns everything that executes outside the app process: the Claude Code session, the git worktree, the gates, and the preflight that proves the environment can run any of it.

Index

Constants

View Source
const MinClaudeVersion = "2.0.0"

MinClaudeVersion is the oldest Claude Code known to support the flags the runner depends on (--session-id, --output-format stream-json, --allowedTools).

Variables

This section is empty.

Functions

func DefaultBase

func DefaultBase(ctx context.Context, dir string) string

DefaultBase asks the remote which branch a PR should target.

Hardcoding "main" is wrong on any repo that uses master, develop or trunk, and a PR opened against a branch that does not exist fails late and confusingly. Falls back to the local HEAD's upstream, then to main.

func GHReady

func GHReady(ctx context.Context) error

GHReady reports whether gh exists AND is authenticated.

Checked before pushing, not before opening the PR: discovering that gh is not logged in AFTER the branch is already on the remote leaves a dangling branch nobody asked for.

func ReapTmuxSession added in v0.2.0

func ReapTmuxSession(log *slog.Logger, name string)

ReapTmuxSession kills a session left behind by a run this process did not finish.

The one leak the kill-on-completion policy cannot close by itself is a builder that dies mid-run: the tmux server outlives it and keeps the session, and nothing in the finished-run path ever executes. The reconciler, which already adopts those orphans to expire their rows and remove their worktrees, calls this to reap the session too.

Silent when tmux is absent or the session is already gone. Both are the normal case, not a fault.

func TmuxSessionName added in v0.2.0

func TmuxSessionName(issue int64, attempt int) string

TmuxSessionName builds the deterministic name for an issue run.

Deterministic on purpose: an operator watching issue 412's second attempt can type the name without looking it up. Unique on purpose too — the attempt number is part of it, and an issue is only ever claimed by one runner at a time, so two live sessions cannot collide.

Types

type Check

type Check struct {
	ID     int    `json:"id"`
	Key    string `json:"key"`
	Label  string `json:"label"`
	Status Status `json:"status"`
	Detail string `json:"detail,omitempty"`
	// Remedy is the exact thing the operator should run or change. A preflight
	// that reports a failure without saying how to fix it just moves the
	// problem into a support channel.
	Remedy string `json:"remedy,omitempty"`
	// Required marks a probe the wizard will not let you skip. gh auth, claude
	// auth and the live execution probe are required because the development
	// loop shells out to both binaries — a project that proceeds without them
	// fails later, in an agent run, where the cause is much harder to see.
	Required bool          `json:"required"`
	Took     time.Duration `json:"took_ns"`
}

Check is one probe result.

type Diff

type Diff struct {
	Files   []string
	Added   int
	Removed int

	// New files versus existing ones, counted apart.
	//
	// Blast radius is what a change DISTURBS, not what it adds. Two thousand
	// lines across five brand-new files cannot break one line of what already
	// works — nothing imports them yet. Three hundred lines rewritten across
	// twenty existing files can break all twenty. Measured as one number those
	// two are indistinguishable, and the second is by far the more dangerous.
	//
	// So the caps are applied to Touched* only, and the totals serve as a
	// runaway guard rather than as the design limit.
	NewFiles       []string
	TouchedFiles   []string
	TouchedAdded   int
	TouchedRemoved int

	HeadSHA    string
	HasChanges bool
}

Diff is what the runner DERIVED from git — never what the agent claimed.

The agent's verdict is a hint about intent. The facts come from the repository, because an agent that believes it changed three files and actually changed thirty is exactly the case the caps exist to catch.

type PublishResult

type PublishResult struct {
	Pushed bool
	URL    string
	Number int
}

PublishResult is what the operator gets back. Pushed can be true while URL is empty: the branch reached the remote but `gh` could not open the PR. Those are genuinely different states and the caller reports them differently — a branch on the remote with no PR still needs a human to know it is there.

func OpenPR

func OpenPR(ctx context.Context, dir, base, branch, title, body string) (PublishResult, error)

OpenPR creates the pull request with gh and returns its URL and number.

The body is passed via a file rather than -b: a verdict containing backticks, $(...) or a newline is ordinary prose from a model, and interpolating it into a shell would make an agent's summary able to run commands. exec.Command does not use a shell, but the file also sidesteps ARG_MAX on a long diff summary.

type Report

type Report struct {
	Checks    []Check   `json:"checks"`
	CheckedAt time.Time `json:"checked_at"`
}

Report is the full preflight.

func Preflight

func Preflight(ctx context.Context) Report

Preflight runs every probe. It is the single code path behind both GET /api/builder/preflight and `togo-builder doctor`, so the wizard and the CLI can never disagree about whether a machine is ready.

Probes are ordered cheapest-first so an obvious failure surfaces immediately, and the two that cost real money or time (18, 20) run last.

func (Report) Blocking

func (r Report) Blocking() []Check

Blocking returns the required probes that are not passing.

func (Report) OK

func (r Report) OK() bool

OK reports whether every required probe passed. Warnings never block.

type Result

type Result struct {
	Text         string  `json:"text"`
	IsError      bool    `json:"is_error"`
	Subtype      string  `json:"subtype"`
	CostUSD      float64 `json:"cost_usd"`
	InputTokens  int64   `json:"input_tokens"`
	OutputTokens int64   `json:"output_tokens"`
	NumTurns     int     `json:"num_turns"`
	DurationMS   int64   `json:"duration_ms"`
	SessionID    string  `json:"session_id"`
	// TmuxSession is the session the run actually executed in, or empty when
	// tmux was unavailable and the process was spawned directly. Returned so a
	// caller that did not choose the name can still persist and display it.
	TmuxSession string        `json:"tmux_session,omitempty"`
	Raw         string        `json:"-"`
	Took        time.Duration `json:"-"`
}

Result is the terminal `result` event plus what we derived from it.

func (Result) JSON

func (r Result) JSON(v any) error

JSON extracts a JSON object from the model's text.

Models wrap JSON in prose or fences however firmly you ask them not to, so the first balanced object is taken rather than the whole string parsed.

type Session

type Session struct {
	// ID is minted by us and passed as --session-id, so the mapping from a
	// database row to a running process exists BEFORE the process does. A crash
	// between spawn and first write still leaves a row that can be reconciled.
	ID string
	// Dir is the working tree. For an implement run this is a git worktree
	// isolated from the operator's checkout.
	Dir string
	// Prompt is the full instruction. Untrusted content inside it must already
	// be wrapped by the caller — see orchestrator.wrapUntrusted.
	Prompt string
	// Model: haiku | sonnet | opus. Empty inherits the CLI default.
	Model string
	// AllowedTools restricts the surface. Empty string means NO tools, which is
	// what a classification pass wants.
	AllowedTools string
	// MaxTurns bounds a runaway loop.
	MaxTurns int
	// PermissionMode: acceptEdits for implement runs. Never bypassPermissions.
	PermissionMode string
	Timeout        time.Duration
	// Env is extra environment for the session, as "KEY=value".
	//
	// This is how the control plane tells the in-session guard hooks what the
	// operator configured. Without it the hooks read .claude/autonomy.yaml and
	// nothing else, so a ceiling edited in the agent settings UI had no effect
	// inside the run — the two disagreed and the file silently won.
	Env []string
	// TmuxSession is the name of the tmux session the run is spawned inside, so
	// an operator can `tmux attach -t <name>` and watch the work happen.
	//
	// Empty means "name it for me" — every run gets a session, because the
	// requirement is that every running task is attachable and a per-call-site
	// opt-in would silently miss whichever call site is added next. Set it
	// explicitly where the name should be GUESSABLE: an issue run uses
	// TmuxSessionName(number, attempt) so the operator can type it from the
	// board without looking anything up.
	TmuxSession string
	// Log is where the tmux wrapper reports. nil takes slog.Default().
	Log *slog.Logger
}

Session is one headless Claude Code invocation.

func (Session) Run

func (s Session) Run(ctx context.Context) (Result, error)

Run executes the session and returns its terminal result.

Never passes --bare: it refuses OAuth entirely and accepts only an API key, so it is incompatible with the subscription auth the preflight verifies. A working machine would look broken.

type Status

type Status string

Status of a single probe.

const (
	StatusPass Status = "pass"
	StatusWarn Status = "warn" // degraded but usable; the loop still runs
	StatusFail Status = "fail"
	StatusSkip Status = "skip"
)

type Workspace

type Workspace struct {
	Repo    string // the main checkout
	Dir     string // the worktree
	Branch  string
	BaseSHA string
}

Workspace is an isolated git worktree for one agent run.

A worktree, not a branch in the main checkout: two agents working concurrently in one directory would overwrite each other, and an operator editing the same files while an agent runs would see their work vanish under a checkout. Each run gets its own directory and its own branch.

func NewWorkspace

func NewWorkspace(ctx context.Context, repo, base, branch string) (*Workspace, error)

NewWorkspace creates a worktree on a fresh branch off the base ref.

func (*Workspace) Commit

func (w *Workspace) Commit(ctx context.Context, msg, agent, model, runID string, issue int64) (string, error)

Commit records the work with provenance in the trailer.

Never `git add -A` at this point — Diff already staged deliberately, and the blast-radius check has run against that staged set. Committing a wider set than was checked would defeat the check.

func (*Workspace) Diff

func (w *Workspace) Diff(ctx context.Context) (Diff, error)

Diff derives what actually changed. This is the only source of truth about a run's effect.

func (*Workspace) Push

func (w *Workspace) Push(ctx context.Context, remote, branch string) error

Push sends the branch to the remote.

--force-with-lease, never --force: if someone else moved the branch since we last saw it, this fails instead of overwriting their work. For a branch the agent just created the distinction rarely matters; on a retry of an issue whose branch a human has already edited, it is the difference between a failed push and lost work.

func (*Workspace) Remove

func (w *Workspace) Remove(ctx context.Context)

Remove tears the worktree down. The branch survives so a PR can be opened from it after the directory is gone.

func (*Workspace) RevertOutside

func (w *Workspace) RevertOutside(ctx context.Context, allowed []string, files []string) ([]string, error)

RevertOutside undoes any change to a path outside the allowlist.

Belt and braces with the guard hooks: a hook constrains the agent's own shell, but a subagent, a script the agent ran, or a tool that writes indirectly can all land a file the hook never saw. This runs after the fact against the real diff, so it catches whatever got through.

Jump to

Keyboard shortcuts

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