taskrun

package
v0.36.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package taskrun is the durable ledger of autonomous task executions.

A Run is IMMUTABLE HISTORY. It snapshots everything that decided what an execution meant — the definition's revision and full text, the resolved project, the trigger occurrence, the expanded authority, the chosen runtime, the limits — at the moment it is created. Nothing re-reads the YAML mid-run, so editing a task file while it executes cannot retroactively change what that run was authorized to do or why it did it.

It lives in its own database rather than the gateway's for two reasons: `memcode task run` has to work on a machine where the daemon has never started (gateway state.OpenShared refuses when there is no gateway.db), and the run ledger is an audit trail that must outlive the gateway's operational state, which is pruned.

Index

Constants

View Source
const Fresh = 2 * time.Minute

Fresh bounds what counts as "this just happened" rather than "this was missed". Generous relative to the poll interval so a slow tick is never mistaken for downtime.

View Source
const StaleAfter = 2 * time.Minute

StaleAfter is how long a running row may go without a heartbeat before Reconcile calls it dead. Generous relative to the heartbeat interval so a loaded machine is never mistaken for a crashed one.

View Source
const TriggerManual = "manual"

TriggerManual is the trigger kind for a run started by hand.

Variables

View Source
var ErrOccupied = fmt.Errorf("this occurrence already has a run")

ErrOccupied means this trigger occurrence already has a run. The caller must NOT execute: someone else owns it.

Functions

func DBPath

func DBPath() (string, error)

DBPath is the ledger's location, alongside the rest of memcode's per-machine state.

func LeaseKey

func LeaseKey(t task.Task, project string) string

LeaseKey is the resource a run needs exclusive use of.

Read-only runs return "" and take no lease at all: they change nothing, so two of them in a repo cannot interfere, and serializing them would only make an audit task block a dependency upgrade for no reason.

A mutating run keys on its RESOLVED PROJECT, so every mutating task in a repo serializes against the others. Worktree isolation may safely relax this later, but one mutating run per project is the conservative default and the right place to start.

func LeaseKeys

func LeaseKeys(t task.Task, project string) []string

LeaseKeys is every resource one run must hold exclusively.

A cross-project run mutates several checkouts, and leasing only the one it was anchored in leaves the others open to a second run working in them at the same time — which is the failure the lease exists to prevent, just moved one project to the left. A custom concurrency key is one key by definition and stays one key.

func NewID

func NewID(now time.Time) string

NewID returns a sortable, unique run identity. Time-prefixed so a directory listing or an ORDER BY id reads chronologically without a join.

func OccurrenceID

func OccurrenceID(tr task.Trigger, at time.Time) string

OccurrenceID is the ledger identity of one firing of one trigger.

func Spec

func Spec(tr task.Trigger) occurrence.Spec

Spec converts a task trigger into the timing form the occurrence package understands.

func Summarize

func Summarize(results []CheckResult) string

Summarize renders check results for a run record.

func TriggerKey

func TriggerKey(tr task.Trigger) string

TriggerKey identifies the SCHEDULE, for storing its watermark.

func Verify

func Verify(ctx context.Context, dir string, commands []string, env ...string) ([]CheckResult, VerificationStatus)

Verify runs a task's checks in order, in dir. Every check runs even after one fails: "which of the four broke" is more useful than "the first one broke", and the cost is bounded by checkTimeout.

Types

type AuthSource

type AuthSource func() runtimes.Authorizations

Authorizations supplies the machine's recorded runtime permissions. Injectable so tests do not depend on whatever is installed on the developer's laptop, and so the daemon and the CLI read the same store.

type CheckResult

type CheckResult struct {
	Command  string
	ExitCode int
	Duration time.Duration
	// Output is the tail of combined stdout/stderr, kept small: enough to see
	// what failed, not enough to bury the run record.
	Output string
	// Err is set when the command could not be run at all, as distinct from
	// running and failing.
	Err string
}

CheckResult is one verification command's outcome.

func (CheckResult) OK

func (c CheckResult) OK() bool

OK reports whether the command succeeded.

type Decision

type Decision struct {
	// Fire are the logical occurrences to run, oldest first. Usually zero or one.
	Fire []time.Time
	// Advance is the new watermark: the newest occurrence now accounted for,
	// whether it ran or was deliberately dropped. Persisting it is what stops a
	// dropped occurrence being rediscovered as missed on the next poll.
	Advance time.Time
	// Dropped counts occurrences deliberately not run. Surfaced on the resulting
	// run so a collapsed backlog is visible rather than silently discarded.
	Dropped int
	// Why explains a non-obvious decision, for the run record.
	Why string
}

Decision is what a poll concluded for one trigger.

func Due

func Due(tr task.Trigger, last, now time.Time) (Decision, error)

Due computes what to run for one trigger, given the watermark of the last occurrence already accounted for.

A zero `last` means the trigger has never been seen. It is then PLACED at its most recent past occurrence rather than treated as having missed everything since the epoch — adding a weekly task on a Friday must not immediately fire it for every Monday in history.

type ErrLeaseHeld

type ErrLeaseHeld struct{ Holder Lease }

ErrLeaseHeld means someone else is working on this resource right now.

func (ErrLeaseHeld) Error

func (e ErrLeaseHeld) Error() string

type Escalation

type Escalation string

Escalation is what a run concluded about the task's future, as distinct from what it concluded about itself.

const (
	// EscalateNone: nothing to say. The default, and the common case.
	EscalateNone Escalation = ""
	// EscalateRetry: a transient condition — an upstream outage, a flaky
	// network. The task is fine; this run was unlucky. Never pauses, because a
	// temporary problem that suspends an automation forever is its own bug.
	EscalateRetry Escalation = "retry_later"
	// EscalateAttention: this run needs a human to look, but the next one is
	// still worth running.
	EscalateAttention Escalation = "needs_attention"
	// EscalatePause: the condition will recur until somebody decides something.
	EscalatePause Escalation = "pause_task"
)

func ParseEscalation

func ParseEscalation(out string) (Escalation, string)

ParseEscalation reads the escalation line a run was asked to end with.

This is a DECLARED protocol, not error-string matching: the agent is told to state its conclusion in a fixed form and this reads that form. The judgement — is this transient, or does it need a person, or will it recur forever — is the model's, made with the whole situation in view. Guessing it from the shape of an error message is what this exists to avoid.

type ExecutionStatus

type ExecutionStatus string

ExecutionStatus is whether the agent's half of the run completed.

const (
	ExecCompleted   ExecutionStatus = "completed"
	ExecFailed      ExecutionStatus = "failed"
	ExecTimedOut    ExecutionStatus = "timed_out"
	ExecInterrupted ExecutionStatus = "interrupted"
	ExecBlocked     ExecutionStatus = "blocked"
)

type Lease

type Lease struct {
	Key         string
	RunID       string
	Task        string
	Host        string
	PID         int
	AcquiredAt  time.Time
	HeartbeatAt time.Time
}

Lease is a held claim on a resource.

func (Lease) Expired

func (l Lease) Expired(now time.Time, host string) bool

Expired reports whether a lease's owner looks gone, by the same rule Reconcile applies to runs.

type Outcome

type Outcome string

Outcome is what a finished run actually achieved. "The agent stopped" is never by itself "the task succeeded", which is the failure mode this distinction exists to prevent.

const (
	OutcomeSuccess Outcome = "success"
	// OutcomeNoChange is a successful run that found nothing to do. Worth its own
	// value: a weekly upgrade task reporting no_change is healthy, and collapsing
	// it into success hides whether the task is still finding anything.
	OutcomeNoChange Outcome = "no_change"
	OutcomeFailed   Outcome = "failed"
	// OutcomeNeedsAttention: the run completed correctly and decided a human must
	// choose. An ambiguous migration it declined to guess at is a SUCCESSFUL
	// execution and an incomplete task.
	OutcomeNeedsAttention Outcome = "needs_attention"
	// OutcomeBlocked: the run could not start its real work (lock held, project
	// missing, no authorized runtime).
	OutcomeBlocked Outcome = "blocked"
	// OutcomeInterrupted: the process vanished mid-run — killed, crashed, or the
	// machine went down. Deliberately NOT retried automatically; see Reconcile.
	OutcomeInterrupted Outcome = "interrupted"
)

func Decide

func Decide(exec ExecutionStatus, verify VerificationStatus, changed bool, agentNeedsAttention bool) Outcome

Decide derives the final outcome from FACTS, never from the agent's prose.

The precedence is deliberate and one-directional: a blocked run cannot be talked up to failed, a failed verification cannot be talked up to success, and an agent's needs_attention signal can only ever make the verdict more cautious. The agent participates in this decision at exactly one point — it may raise needs_attention — and has no path to lower it.

type Pause

type Pause struct {
	Task    string
	Project string
	Reason  string
	RunID   string
	Since   time.Time
}

Pause is a task's suspension, with the evidence needed to resolve it.

func (Pause) String

func (p Pause) String() string

String renders a pause for a human deciding whether to deal with it now.

type PollResult

type PollResult struct {
	Started []Run
	// Skipped counts occurrences deliberately not run, across all triggers.
	Skipped int
	// Errs holds per-task failures; one bad task never stops the others.
	Errs []error
}

PollResult reports what one poll did.

type Result

type Result struct {
	Outcome      Outcome
	Summary      string
	Detail       string
	LogPath      string
	ExecStatus   ExecutionStatus
	VerifyStatus VerificationStatus
	Checks       string
	Worktree     string
	Branch       string
	BaseRev      string
	ResultRev    string
	Changed      bool

	Remote        string
	CommitSHA     string
	PRNumber      int
	PRURL         string
	CreatedBranch bool
	CreatedCommit bool
	CreatedPR     bool

	// Escalation is what the run concluded about FUTURE runs, as distinct from
	// this one. Not persisted on the run row: its consequence is a task pause,
	// which is its own durable record.
	Escalation    Escalation
	EscalationWhy string
}

Finish closes a run. Terminal and idempotent: a run already done stays as it was, so a late finisher cannot overwrite an interrupted verdict. Result is everything a finished run records beyond its outcome.

func (Result) OKOutcome

func (r Result) OKOutcome() bool

OKOutcome reports whether the result is one nobody needs to look at.

type Run

type Run struct {
	ID   string
	Task string

	// Frozen definition. Revision identifies it; Definition is the full YAML, so
	// a run stays readable even after the file is deleted.
	Revision   string
	Definition string

	// TriggerKind is manual/cron/every/at. TriggerID identifies the specific
	// OCCURRENCE and is the idempotency key: empty for manual runs (asking twice
	// means twice), unique per firing otherwise.
	TriggerKind string
	TriggerID   string

	Project    string
	Grants     []string
	Timeout    time.Duration
	MaxCostUSD float64

	// Where the inference ran. Requested and resolved are both frozen at
	// creation, so the choice stays explainable after the machine's state and
	// the user's authorizations have moved on.
	RuntimeRequested string
	RuntimeResolved  string
	ModelRequested   string
	ModelResolved    string
	CredSource       string
	AuthID           string
	AuthScope        string

	State   State
	Outcome Outcome
	Summary string
	Detail  string
	LogPath string

	// OccurredAt is the LOGICAL time this run stands for, which is not when it
	// started: a run_once recovery of Monday's occurrence executed on Wednesday
	// occurred-at Monday. That is what makes a recovered run explainable.
	OccurredAt time.Time
	// Backlog counts occurrences dropped when this one was created.
	Backlog int

	// Execution and verification, kept separable from Outcome.
	ExecStatus   ExecutionStatus
	VerifyStatus VerificationStatus
	Checks       string

	// Where the work happened. BaseRev is resolved at EXECUTION time, so a
	// recovered occurrence is honest about building on today's HEAD rather than
	// implying the repository was frozen when it was due.
	Worktree  string
	Branch    string
	BaseRev   string
	ResultRev string
	Changed   bool

	// What was published, and which artifacts this run created rather than
	// found already there.
	Remote        string
	CommitSHA     string
	PRNumber      int
	PRURL         string
	CreatedBranch bool
	CreatedCommit bool
	CreatedPR     bool

	Host string
	PID  int

	StartedAt   time.Time
	HeartbeatAt time.Time
	FinishedAt  time.Time
	Seen        Seen
}

Run is one execution, with its inputs frozen at creation.

func Freeze

func Freeze(t task.Task, root, triggerKind, triggerID string, now time.Time) (Run, error)

Freeze captures a task's execution inputs as a Run, resolving everything that could otherwise drift: the definition's revision AND full text, the absolute project path, the expanded authority, the runtime, the limits.

Nothing downstream reads the task file again. That is what makes a run mean one fixed thing forever, and it is why the definition is stored whole rather than by reference — a run stays readable after its file is edited or deleted.

func FreezeAt

func FreezeAt(t task.Task, root, triggerKind, triggerID string, occurredAt, now time.Time, backlog int) (Run, error)

FreezeAt is Freeze with the LOGICAL occurrence this run stands for, and the size of the backlog collapsed into it. A run_once recovery of Monday's occurrence executed on Wednesday is occurredAt Monday: the ledger then says which firing was recovered, instead of only when someone got round to it.

func FreezeWith

func FreezeWith(t task.Task, root, triggerKind, triggerID string, occurredAt, now time.Time,
	backlog int, rt runtimes.Resolution,
) (Run, error)

FreezeWith is FreezeAt plus the resolved runtime, frozen with everything else.

func (Run) OK

func (r Run) OK() bool

OK reports whether a finished run did what it was supposed to. no_change is a success: the task ran correctly and there was nothing to do.

func (Run) Terminal

func (r Run) Terminal() bool

Terminal reports whether the run has finished.

type Runner

type Runner struct {
	Store *Store
	// Spawn runs the agent and blocks until it finishes. Injectable so tests
	// exercise the whole lifecycle — including crashes — without a model call.
	Spawn SpawnFunc
	// HeartbeatEvery bounds how stale a live run's heartbeat can get.
	HeartbeatEvery time.Duration
	// Auth and Available answer "what is permitted" and "what is present".
	// Injectable so a test's answer does not depend on the developer's laptop.
	Auth      AuthSource
	Available func() []string
}

Runner turns a task definition into a durable, executed Run.

The order matters and is the point of the whole design:

freeze inputs -> create the row (claims the occurrence) -> claim the work
-> execute -> persist the outcome

The row exists before any work starts, so a duplicate dispatch is rejected by the ledger rather than by hoping two executions do not overlap.

func NewRunner

func NewRunner(store *Store, auth AuthSource) *Runner

NewRunner builds a runner backed by real detached memcode processes.

func (*Runner) Execute

func (r *Runner) Execute(ctx context.Context, run Run, t task.Task) (Run, error)

Execute claims and runs an already-created run to completion, persisting the outcome. Safe to call only once per run; the conditional claim enforces that.

func (*Runner) Poll

func (r *Runner) Poll(ctx context.Context, tasks []task.Task, root string, now time.Time) PollResult

Poll fires everything due. It returns once every started run has finished, so a caller that wants concurrency across tasks runs Poll in a goroutine.

func (*Runner) Run

func (r *Runner) Run(ctx context.Context, t task.Task, root, triggerKind, triggerID string) (Run, error)

Run is the whole loop: freeze, record, claim, execute, persist.// Run is the whole loop: freeze, record, claim, execute, persist.

func (*Runner) Start

func (r *Runner) Start(ctx context.Context, t task.Task, root, triggerKind, triggerID string, now time.Time) (Run, error)

Start freezes, records and claims a run without executing it. Returns ErrOccupied when the occurrence already belongs to another run.

type Seen

type Seen string

Seen tracks whether a human has dealt with a run's result. Three states, not a boolean: a needs_attention run that scrolled past in a session banner has been SEEN, not dealt with, and collapsing those loses the distinction exactly where it matters most.

const (
	SeenUnseen       Seen = "unseen"
	SeenSeen         Seen = "seen"
	SeenAcknowledged Seen = "acknowledged"
)

type SpawnFunc

type SpawnFunc func(ctx context.Context, req SpawnRequest) (SpawnResult, error)

SpawnFunc executes a task's work and reports what happened.

type SpawnRequest

type SpawnRequest struct {
	RunID string
	// Project is the repository that owns the run's bookkeeping — its job log
	// outlives a disposable worktree because of this.
	Project string
	// WorkDir is where the work actually happens: the isolated worktree, or the
	// project itself when there is nothing to isolate.
	WorkDir      string
	Instructions string
	Mode         permissions.Mode
	ReadOnly     bool
	// Env selects the resolved runtime for the child.
	Env []string
	// DenyTools and DenyCommands are the capability ceiling, projected from the
	// task's grants. Both are real restrictions on the child.
	DenyTools    []string
	DenyCommands []string
	Timeout      time.Duration
}

SpawnRequest is everything the executor needs, taken from the FROZEN run rather than from the task file, so a mid-run YAML edit cannot reach it.

type SpawnResult

type SpawnResult struct {
	Text     string
	LogPath  string
	ExitCode int
}

SpawnResult is the executor's report.

type State

type State string

State is where a run is in its lifecycle. Terminal state is always Done; what actually happened is the Outcome.

const (
	// StatePending: the row exists and owns its occurrence, but no process has
	// claimed the work yet. Creating the row before doing anything is what makes
	// at-least-once dispatch safe — the unique occurrence index rejects a second
	// row before a second execution can start.
	StatePending State = "pending"
	// StateRunning: a live process holds it, identified by host and pid and
	// proven by a heartbeat.
	StateRunning State = "running"
	// StateDone: terminal. Read Outcome.
	StateDone State = "done"
)

type Store

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

Store is the run ledger.

func Open

func Open(ctx context.Context, path string) (*Store, error)

Open creates or opens the ledger. There is deliberately NO exclusive lock: the CLI, the daemon and spawned children all touch this concurrently, and WAL plus a busy timeout is what makes that safe. The gateway's own singleton lock exists to keep one inbox worker, which is a different problem.

func OpenDefault

func OpenDefault(ctx context.Context) (*Store, error)

OpenDefault opens the ledger at its standard location.

func (*Store) Acknowledge

func (s *Store) Acknowledge(ctx context.Context, id string) error

Acknowledge records that a human actually dealt with a run.

func (*Store) Acquire

func (s *Store) Acquire(ctx context.Context, key, runID, taskName string, now time.Time) error

Acquire takes a lease, stealing one whose owner is gone. An empty key is a no-op success: a read-only run needs no exclusivity.

func (*Store) Active

func (s *Store) Active(ctx context.Context, task string) ([]Run, error)

Active returns runs that are not finished, for one task.

func (*Store) Claim

func (s *Store) Claim(ctx context.Context, id string, now time.Time) (bool, error)

Claim moves a pending run to running and stamps the owning process. The UPDATE is conditional on the row still being pending, so two processes racing for the same run cannot both win: the loser sees claimed=false.

func (*Store) Close

func (s *Store) Close() error

func (*Store) Create

func (s *Store) Create(ctx context.Context, r Run) (Run, error)

Create records a new run in StatePending, claiming its occurrence. It returns ErrOccupied when a triggered occurrence is already accounted for — that is the idempotency boundary doing its job, and the caller must treat it as "someone else has this", never as an error to retry through.

func (*Store) Finish

func (s *Store) Finish(ctx context.Context, id string, outcome Outcome, summary, detail, logPath string, now time.Time) error

func (*Store) FinishResult

func (s *Store) FinishResult(ctx context.Context, id string, r Result, now time.Time) error

FinishResult closes a run with its full structured verdict.

func (*Store) Get

func (s *Store) Get(ctx context.Context, id string) (Run, error)

Get loads one run.

func (*Store) Heartbeat

func (s *Store) Heartbeat(ctx context.Context, id string, now time.Time) error

Heartbeat proves the owning process is still alive. Reconcile uses its absence to tell a crashed run from a slow one.

func (*Store) MarkSeen

func (s *Store) MarkSeen(ctx context.Context, ids []string) error

MarkSeen advances unseen runs to seen. It never touches an acknowledged run and never moves backwards: showing someone a banner is not the same as them dealing with it, which is the whole reason this is not a boolean.

func (*Store) Note

func (s *Store) Note(ctx context.Context, id, note string) error

Note records why a run exists — a recovered occurrence, a collapsed backlog — before it executes, so the explanation survives even if the run then crashes. Appends rather than replaces: a run may accumulate more than one note.

func (*Store) Pause

func (s *Store) Pause(ctx context.Context, p Pause) error

Pause suspends a task's unattended execution. Idempotent on the FIRST reason: re-pausing an already-paused task must not overwrite the original diagnosis with a later, vaguer one.

func (*Store) Paused

func (s *Store) Paused(ctx context.Context) ([]Pause, error)

Paused lists every suspended task, oldest first: a responsibility memcode stopped fulfilling longest ago is the one most worth raising.

func (*Store) PausedTask

func (s *Store) PausedTask(ctx context.Context, taskName, project string) (Pause, bool, error)

PausedTask reports a task's suspension, if it has one.

func (*Store) Recent

func (s *Store) Recent(ctx context.Context, task string, limit int) ([]Run, error)

Recent returns the newest runs, optionally for one task.

func (*Store) Reconcile

func (s *Store) Reconcile(ctx context.Context, now time.Time) (int, error)

Reconcile settles runs left behind by a process that died — a killed daemon, a crash, a machine that went down mid-run.

The semantic is deliberate and narrow: such a run is FAILED as interrupted, never silently resumed and never silently re-run. A partially finished mutating run cannot be safely continued by a new process that did not see what the old one did, and re-running it automatically is exactly how at-least-once dispatch turns into duplicate side effects. If the work still needs doing, that is a NEW run with a new identity, which the ledger shows.

A run is considered dead when its heartbeat is older than StaleAfter, or when it is owned by this host and its pid is gone (which is immediate and does not wait out the timeout).

func (*Store) Release

func (s *Store) Release(ctx context.Context, key, runID string) error

Release drops a lease, but only if this run still holds it — a run that was already evicted as dead must not delete its successor's claim.

func (*Store) Renew

func (s *Store) Renew(ctx context.Context, key, runID string, now time.Time) error

Renew keeps a held lease alive.

func (*Store) Resume

func (s *Store) Resume(ctx context.Context, taskName, project string) (bool, error)

Resume lifts a suspension. Reports whether one was actually there.

func (*Store) SetWatermark

func (s *Store) SetWatermark(ctx context.Context, taskName, triggerKey string, at, now time.Time) error

SetWatermark records an occurrence as accounted for — whether it ran or was deliberately dropped. Advancing on a drop is what stops a skipped occurrence being rediscovered as missed on the next poll, forever.

It never moves backwards, so an out-of-order poll cannot rewind the series.

func (*Store) Unseen

func (s *Store) Unseen(ctx context.Context, limit int) ([]Run, error)

Unseen returns finished runs a human has not looked at yet, oldest first so a session banner reads in the order things happened.

func (*Store) Watermark

func (s *Store) Watermark(ctx context.Context, taskName, triggerKey string) (time.Time, error)

Watermark returns the newest occurrence already accounted for on a trigger.

type VerificationStatus

type VerificationStatus string

VerificationStatus is the verdict over all checks, kept separate from whether the agent's execution completed. A run whose agent finished cleanly and whose tests then failed is an execution success and a task failure, and collapsing those two into one enum is how that distinction gets lost.

const (
	// VerifyNone: the task declared no checks. Not a pass — an absence.
	VerifyNone VerificationStatus = "none"
	VerifyPass VerificationStatus = "passed"
	VerifyFail VerificationStatus = "failed"
	// VerifySkipped: the run never got far enough to verify.
	VerifySkipped VerificationStatus = "skipped"
)

Jump to

Keyboard shortcuts

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