task

package
v0.35.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: 16 Imported by: 0

Documentation

Overview

Package task is memcode's autonomous Task: a reusable, declarative, auditable unit of work the agent can execute with no human present.

A Task is NOT inherently recurring. It is a runnable object first — `memcode task run <name>` is a first-class entry point, not a testing affordance — and a trigger is an optional attachment. That ordering is the whole point: cron is one way a Task becomes eligible, never what a Task is.

Three objects stay deliberately separate:

Task     what to do, how, with what authority   (YAML, hashable)
Trigger  when it becomes eligible               (0..n, optional)
Run      one immutable execution                (a DB row, package taskrun)

Every run records the Revision of the definition that produced it, so "why did this task push that branch?" stays answerable after the YAML has been edited five times. The autonomy package hashes delegation policies for the same reason and by the same method (see autonomy.CanonicalPolicy).

Index

Constants

View Source
const (
	DefaultMaxCatchUp = 10
	HardMaxCatchUp    = 100
)

DefaultMaxCatchUp bounds catch_up when a trigger does not say. HardMaxCatchUp bounds what a trigger may ASK for: a YAML value is a ceiling request, not an override, and nothing should be able to schedule a thousand-run stampede.

View Source
const ConcurrencyKeyProject = "project"

ConcurrencyKeyProject is the default key: one mutating run per repo.

View Source
const DefaultBranchPattern = "auto/{name}-{date}"

DefaultBranchPattern names the branch an autonomous run pushes. {name} and {date} are substituted; the auto/ prefix makes provenance obvious in a branch list.

View Source
const DefaultTimeout = "45m"

DefaultTimeout bounds a run that does not set its own.

View Source
const DirName = "tasks"

DirName is the tasks subdirectory inside either root.

View Source
const ModelAuto = "auto"

ModelAuto means the task expressed no model preference.

View Source
const Version = 1

Version is the schema version every task file must declare. An explicit version on a user-editable file is what lets the format change later without guessing at the shape of what is on disk.

Variables

View Source
var ErrNotFound = errors.New("task not found")

ErrNotFound is returned by Get when no task carries that name.

Functions

func GlobalDir

func GlobalDir() (string, error)

GlobalDir returns the per-machine task directory.

func Marshal

func Marshal(t Task) ([]byte, error)

Marshal renders a task back to YAML, defaults applied. Used when memcode writes a task it proposed, so the file on disk is the complete definition rather than a sparse one whose behaviour shifts if a default ever changes.

func ProjectDir

func ProjectDir(root string) string

ProjectDir returns the repo-local task directory.

func Save

func Save(root string, t Task, scope Scope) (string, error)

Save writes a task to the chosen scope's directory, creating it if needed. The write is atomic so a crash mid-save cannot leave the scheduler reading a truncated definition.

func ValidLevel

func ValidLevel(l Level) bool

ValidLevel reports whether l names a shipped tier.

func ValidName

func ValidName(s string) bool

ValidName reports whether s is a usable task name.

Types

type Autonomy

type Autonomy struct {
	Level  Level   `yaml:"level,omitempty" json:"level,omitempty"`
	Grants []Grant `yaml:"grants,omitempty" json:"grants,omitempty"`
}

Autonomy is the task's authority CEILING. Level is a friendly preset that expands to concrete grants; Grants adds named capabilities on top. The policy engine only ever reasons about grants.

A ceiling is not an override. Nothing written here can lift a task above memcode's hard floor — permissions.Decide still returns NeedPrompt for a catastrophic command in every mode, and an unknown grant is refused at parse time rather than interpreted generously.

type Capability

type Capability struct {
	// DenyTools are tool names or toolset names the child must not have.
	DenyTools []string
	// DenyCommands are shell command patterns the child may not run, matched
	// against the AST (see permissions.DeniedBy) so wrappers cannot smuggle one.
	DenyCommands []string
	// ProtectProject means the run must not execute in the user's checkout.
	// The runner satisfies it with a throwaway worktree, which is what makes
	// "read-only" mean "cannot change YOUR project" rather than the impractical
	// "performs no writes anywhere" — builds and tests write caches and temp
	// files constantly, and forbidding that would forbid the work.
	ProtectProject bool
	// Mutating reports whether this run may change project state at all. Drives
	// worktree retention and the concurrency lease.
	Mutating bool
}

Capability is the projection of a task's grants onto what its run receives.

type Concurrency

type Concurrency struct {
	Key    string            `yaml:"key,omitempty" json:"key,omitempty"`
	Policy ConcurrencyPolicy `yaml:"policy,omitempty" json:"policy,omitempty"`
}

Concurrency serializes runs that share a key. Key "project" (the default) resolves to the task's project root, so every mutating task in a repo takes the same lock while read-only tasks run freely.

type ConcurrencyPolicy

type ConcurrencyPolicy string

ConcurrencyPolicy decides what happens when a task is due while an overlapping run is still going.

const (
	// ConcurrencyQueue waits for the in-flight run. The default: two tasks
	// rewriting the same repo at once is a merge conflict with extra steps.
	ConcurrencyQueue ConcurrencyPolicy = "queue"
	// ConcurrencySkip drops this occurrence.
	ConcurrencySkip ConcurrencyPolicy = "skip"
)

type Coordination

type Coordination string

Coordination is the publication semantics of a multi-project run.

const (
	// CoordIndependent publishes each project's work on its own merits. Right
	// when the projects merely share a chore ("keep dependencies current"):
	// repo B being stuck is no reason to withhold repo A's upgrade.
	CoordIndependent Coordination = "independent"

	// CoordCoordinated publishes all of it or none of it. Right when the
	// changes only make sense together, which is the whole reason the task is
	// cross-project rather than two tasks. A partial success is not a success;
	// it is a half-applied change to a system that was consistent before.
	CoordCoordinated Coordination = "coordinated"
)

type Delivery

type Delivery struct {
	Desktop Notify `yaml:"desktop,omitempty" json:"desktop,omitempty"`
	// Channel reuses the gateway's existing "channel:conversation" address when
	// the user has one paired. Empty means inbox only.
	Channel   string `yaml:"channel,omitempty" json:"channel,omitempty"`
	ChannelOn Notify `yaml:"channel_on,omitempty" json:"channel_on,omitempty"`
}

Delivery is where results go. The inbox is the durable ledger and is always on; everything else is notification layered over it.

type Execution

type Execution struct {
	// Steps are the mechanical part of the job, worked out when the task was
	// SET UP rather than learned from it later. The design phase already runs
	// the work once to validate it, and that run is where it becomes obvious
	// which commands are deterministic — `go get -u ./...`, fetch, normalise,
	// build — and which needed someone to think.
	//
	// A run does the mechanical part directly and pays no model for it. If the
	// steps do everything the goal needed and nothing changed, the run is over
	// and no agent starts at all: the common Monday costs nothing.
	//
	// They are an OPTIMISATION and never the definition. A step that fails is
	// drift, not a verdict — the agent takes over and works out the current
	// state. Nothing here can make a run succeed; only verification does that.
	Steps []string `yaml:"steps,omitempty" json:"steps,omitempty"`
	// KnownGood records an approach that WORKED once, with the date it worked.
	// It is a hint and never the definition: a repository drifts, and a run
	// that treats last quarter's steps as authoritative will confidently do the
	// wrong thing. A run starts here and checks whether the assumptions still
	// hold before relying on any of it.
	KnownGood string `yaml:"known_good,omitempty" json:"known_good,omitempty"`
}

Execution selects the work strategy. Procedure names an entry in the repo's procedure store; the concept is deliberately not "shell script", because a procedure may later be an HTTP call, a tool sequence or a Go helper.

type Git

type Git struct {
	Worktree    *bool  `yaml:"worktree,omitempty" json:"worktree,omitempty"`
	PullRequest PRMode `yaml:"pull_request,omitempty" json:"pull_request,omitempty"`
	Branch      string `yaml:"branch,omitempty" json:"branch,omitempty"`
	// Remote is where a branch is published. Default origin.
	Remote string `yaml:"remote,omitempty" json:"remote,omitempty"`
}

Git controls how code changes leave an unattended run. Worktree isolation is the default because an autonomous run must never disturb the branch a human is sitting on.

type Grant

type Grant string

Grant is one named capability an autonomous run may exercise.

const (
	// Read-only capabilities.
	GrantFilesystemRead      Grant = "filesystem.read"
	GrantProcessExecReadOnly Grant = "process.execute_readonly"

	// Local mutation.
	GrantFilesystemMutate Grant = "filesystem.local_mutation"
	GrantProcessExec      Grant = "process.execute"

	// Preparing a change for humans.
	GrantGitCreateBranch Grant = "git.create_branch"
	GrantGitCommit       Grant = "git.commit"
	GrantGitPushBranch   Grant = "git.push_branch"
	GrantGitHubOpenPR    Grant = "github.open_pr"
)

func ExpandGrants

func ExpandGrants(level Level, extra []Grant) ([]Grant, error)

ExpandGrants resolves a task's authority to the concrete set the policy engine enforces: the level's preset plus any explicitly named additions, deduplicated and sorted. An unknown grant or level is an error — a typo must never silently widen or narrow authority.

func KnownGrants

func KnownGrants() []Grant

KnownGrants lists every grant a task file may name, sorted.

type Level

type Level string

Level is a preset bundle of grants.

const (
	// LevelReadOnly inspects, runs read-only commands, and reports. It changes
	// nothing anywhere, including in a worktree.
	LevelReadOnly Level = "read_only"

	// LevelBranch is the default. It may edit an isolated worktree, run
	// commands, commit, push a NEW branch, and open a pull request — the full
	// shape of "prepare a change for review" and nothing beyond it.
	//
	// Opening a PR belongs here rather than in some higher tier. It does mutate
	// remote state, but a PR is a review artifact: it is precisely the act of
	// asking a human to decide. The invariant this whole feature is built
	// around is that autonomous work may PREPARE a change, never make it
	// authoritative.
	LevelBranch Level = "branch"
)

func Levels

func Levels() []Level

Levels lists the valid levels, for error messages and shell completion.

type Limits

type Limits struct {
	Timeout    string  `yaml:"timeout,omitempty" json:"timeout,omitempty"`
	MaxCostUSD float64 `yaml:"max_cost_usd,omitempty" json:"max_cost_usd,omitempty"`
}

Limits bound a single run. An unattended task that can spin forever is a bill, not a feature.

type Missed

type Missed string

Missed is the catch-up policy for a trigger the daemon slept through. This matters more than it looks on a laptop: without it, "0 2 * * *" silently means "only if the machine happened to be awake at 2am", and a weekly job can go months without running while appearing healthy.

const (
	// MissedRunOnce runs the task once on the next daemon start, then resumes
	// the normal cadence. The default, and the right answer for maintenance.
	MissedRunOnce Missed = "run_once"
	// MissedSkip forgets the occurrence entirely (the old schedule behaviour).
	MissedSkip Missed = "skip"
	// MissedCatchUp replays missed occurrences, up to Trigger.MaxCatchUp. Rarely
	// what anyone wants.
	MissedCatchUp Missed = "catch_up"
)

type Notify

type Notify string

Notify says when an optional sink fires.

const (
	NotifyNever    Notify = "never"
	NotifyFailures Notify = "failures"
	NotifyChanges  Notify = "changes"
	NotifyAlways   Notify = "always"
)

type OnPush

type OnPush struct {
	Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
}

OnPush and Webhook are reserved trigger kinds, rejected by Validate today.

type Ownership

type Ownership struct {
	// Projects are the checkouts this responsibility spans. Empty means the
	// task's own Project and nothing else, which is the common case.
	Projects []string `yaml:"projects,omitempty" json:"projects,omitempty"`

	// Coordination decides what happens when the work succeeds in some
	// projects and not others. This is a real product decision, not a detail:
	// "PR opened in repo A" while repo B could not be updated is a WORSE
	// outcome than doing nothing, if the two changes only make sense together.
	Coordination Coordination `yaml:"coordination,omitempty" json:"coordination,omitempty"`

	// Responsibility states what this task is responsible FOR, independently of
	// today's file layout. It is what a run judges its own share against when
	// the repository has moved under it.
	//
	// It is NOT a licence to go looking for more projects. The approved project
	// set is fixed at creation and changes only when the user changes the
	// automation. Drift is allowed WITHIN an approved boundary — paths, build
	// commands, package managers, moved code, refactors, whatever the run finds
	// there. Expanding the boundary is not drift; it is new authority, and
	// authority comes from a person.
	//
	// So if the responsibility grows into a repository nobody approved, the run
	// stops and says so. Discovery is not authorization, the same way a login
	// found in another tool's files is not consent to use it.
	Responsibility string `yaml:"responsibility,omitempty" json:"responsibility,omitempty"`
}

Ownership answers "whose responsibility is this?", which is not the same question as "where was the conversation happening?".

A task created while someone happened to be sitting in one checkout can easily belong to something larger. memcode itself is the example: the model catalog is one responsibility implemented in two repositories, and a task that updated only whichever one was open would be quietly wrong forever.

So a task names the projects its responsibility spans, and how their work relates. Project is still where the task is ANCHORED — the repo it was created against, and the one whose worktree a single-project run uses. Projects widens that to the full set when the responsibility is genuinely cross-cutting.

type PRMode

type PRMode string

PRMode decides whether a task that changed code opens a pull request.

const (
	PRNever PRMode = "never"
	// PRWhenChanges opens a pull request when the run produced a commit.
	PRWhenChanges PRMode = "when_changes"
	// PRAlways means always FOR A PRODUCED COMMIT — never "manufacture an empty
	// one to satisfy the configuration". A run with no diff opens nothing under
	// either setting; the difference between them is reserved for future
	// conditions on an actual change, not for inventing artifacts.
	PRAlways PRMode = "always"
)

type Runtime

type Runtime struct {
	Strategy string   `yaml:"strategy,omitempty" json:"strategy,omitempty"`
	Allowed  []string `yaml:"allowed,omitempty" json:"allowed,omitempty"`
	Model    string   `yaml:"model,omitempty" json:"model,omitempty"`
	Fallback []string `yaml:"fallback,omitempty" json:"fallback,omitempty"`
}

Runtime is where the task's inference runs. It is four separate decisions rather than one string, because "use my Claude subscription" quietly bundles availability, permission, capability and what-to-do-when-it-breaks, and those have different answers and different owners.

Strategy  how to choose
Allowed   the user's ordered preference — order is obeyed, not optimized
Model     a pinned catalog model, or auto
Fallback  what may be tried when the RUNTIME fails (not when the task does)

A runtime being present on the machine authorizes nothing. See internal/runtimes.

type Scope

type Scope string

Scope says where a definition was loaded from. Project scope wins over global on a name collision: a repo that ships its own task means it for that repo.

const (
	ScopeProject Scope = "project"
	ScopeGlobal  Scope = "global"
)

type Task

type Task struct {
	Version      int         `yaml:"version" json:"version"`
	Name         string      `yaml:"name" json:"name"`
	Description  string      `yaml:"description,omitempty" json:"description,omitempty"`
	Enabled      *bool       `yaml:"enabled,omitempty" json:"enabled,omitempty"`
	Project      string      `yaml:"project,omitempty" json:"project,omitempty"`
	Ownership    Ownership   `yaml:"ownership,omitempty" json:"ownership"`
	Triggers     []Trigger   `yaml:"triggers,omitempty" json:"triggers,omitempty"`
	Instructions string      `yaml:"instructions" json:"instructions"`
	Execution    Execution   `yaml:"execution,omitempty" json:"execution"`
	Runtime      Runtime     `yaml:"runtime,omitempty" json:"runtime"`
	Autonomy     Autonomy    `yaml:"autonomy,omitempty" json:"autonomy"`
	Git          Git         `yaml:"git,omitempty" json:"git"`
	Verify       Verify      `yaml:"verify,omitempty" json:"verify"`
	Delivery     Delivery    `yaml:"delivery,omitempty" json:"delivery"`
	Limits       Limits      `yaml:"limits,omitempty" json:"limits"`
	Concurrency  Concurrency `yaml:"concurrency,omitempty" json:"concurrency"`

	// Resolved by the loader, never authored and never hashed.
	Path  string `yaml:"-" json:"-"`
	Scope Scope  `yaml:"-" json:"-"`
}

Task is one task definition, as authored in YAML plus the resolved facts the loader attaches. Zero-value fields are filled by ApplyDefaults before validation or hashing, so a sparse file and its fully-written equivalent produce the same Revision.

func Get

func Get(root, name string, now time.Time) (Task, error)

Get resolves one task by name, honouring the same precedence as Load.

func Load

func Load(root string, now time.Time) ([]Task, []error)

Load returns every task visible from root, project scope shadowing global on a name collision, sorted by name. Malformed files are returned separately so a caller can surface them without losing the tasks that did load.

func Parse

func Parse(data []byte, path string, scope Scope, now time.Time) (Task, error)

Parse decodes one task file. Decoding is STRICT — an unknown key is an error rather than a silently ignored line, because a typo'd `autonmy:` that parses cleanly would hand a task the default authority while its author believed otherwise.

func (*Task) ApplyDefaults

func (t *Task) ApplyDefaults()

ApplyDefaults fills every unset field with its documented default. It runs before validation and before hashing, so a sparse file and the fully-written equivalent are the same task with the same Revision.

func (Task) Capability

func (t Task) Capability() (Capability, error)

Project returns the capability projection for a task.

func (Task) CrossProject

func (t Task) CrossProject() bool

CrossProject reports whether this responsibility spans more than one checkout.

func (Task) Grants

func (t Task) Grants() ([]Grant, error)

Grants resolves this task's effective authority.

func (Task) HasGrant

func (t Task) HasGrant(g Grant) bool

HasGrant reports whether the task's expanded authority includes g. A resolution error means "no": authority questions fail closed.

func (Task) IsEnabled

func (t Task) IsEnabled() bool

IsEnabled reports whether the task may run. Absent means enabled — a file someone wrote is meant to work.

func (Task) Manual

func (t Task) Manual() bool

Manual reports whether the task only ever runs by hand. A task with no triggers is manual — that is a normal, complete task, not an unfinished one.

func (Task) MayMutate

func (t Task) MayMutate() bool

MayMutate reports whether the task can change anything on disk.

func (Task) MayOpenPR

func (t Task) MayOpenPR() bool

MayOpenPR reports whether the task can push a branch AND open a pull request. Both are required: a PR with nothing pushed behind it is not a thing.

func (Task) ReadOnly

func (t Task) ReadOnly() bool

ReadOnly reports whether this task may change anything at all.

func (Task) ResolveProject

func (t Task) ResolveProject(root string) (string, error)

ResolveProject returns the absolute, symlink-resolved directory a task runs in: its own project field when set, else the root it was loaded from.

func (Task) Revision

func (t Task) Revision() (string, error)

Revision is the content hash of the normalized definition: sha256 over canonical JSON, sorted where order carries no meaning. Two files that differ only in key order or in omitted-but-defaulted fields hash identically; any semantic edit changes the hash.

Every run records this. It is what makes an execution auditable months later, after the YAML has moved on.

func (Task) Targets

func (t Task) Targets() []string

Targets is the set of checkouts a run must work through, anchor first.

Always non-empty for a valid task, so callers never special-case the single-project shape.

func (Task) TargetsFrom

func (t Task) TargetsFrom(anchor string) []string

TargetsFrom is Targets with the anchor supplied by the caller.

A definition does not always carry its own project: a global task is anchored by the run, and a file authored without a `project:` key is anchored by where it was loaded from. The anchor is a property of the RUN in those cases, and pretending otherwise silently produces an empty target list — which for the lease means no lock at all.

func (Task) Timeout

func (t Task) Timeout() time.Duration

Timeout resolves the run bound.

func (Task) UsesWorktree

func (t Task) UsesWorktree() bool

UsesWorktree reports whether runs get an isolated worktree. Absent means yes.

func (Task) Validate

func (t Task) Validate(now time.Time) error

Validate checks a task after ApplyDefaults. Every failure names the field and says what a working value looks like, because the reader is usually someone hand-editing YAML with no schema in front of them.

now is injectable so one-shot "at" triggers can be validated against a fixed clock in tests.

type Trigger

type Trigger struct {
	Cron  string `yaml:"cron,omitempty" json:"cron,omitempty"`
	Every string `yaml:"every,omitempty" json:"every,omitempty"`
	At    string `yaml:"at,omitempty" json:"at,omitempty"`
	// TZ evaluates Cron in a named zone ("America/Los_Angeles"); empty = local.
	TZ string `yaml:"tz,omitempty" json:"tz,omitempty"`
	// Missed decides what happens when the daemon was not running at the moment
	// this trigger was due. Meaningless for Manual.
	Missed Missed `yaml:"missed,omitempty" json:"missed,omitempty"`
	// MaxCatchUp bounds how many missed occurrences catch_up will actually run.
	// Without a bound, a laptop returning after months with an hourly task would
	// enqueue thousands of historical runs at once. Ignored unless Missed is
	// catch_up; the excess is dropped newest-first-kept and surfaced on the run.
	MaxCatchUp int `yaml:"max_catch_up,omitempty" json:"max_catch_up,omitempty"`
	// Manual is an explicit "this trigger only fires by hand". A task with no
	// triggers at all is already manual-only; this exists so a file can say so.
	Manual bool `yaml:"manual,omitempty" json:"manual,omitempty"`

	// Reserved kinds. Declared so an author who writes one gets a clear "not
	// implemented yet" instead of an unknown-field parse error, and so the
	// schema slot is taken.
	OnPush  *OnPush  `yaml:"on_push,omitempty" json:"on_push,omitempty"`
	Webhook *Webhook `yaml:"webhook,omitempty" json:"webhook,omitempty"`
}

Trigger is one way a task becomes eligible to run. Exactly one kind may be set. The list is plural from day one so adding a second kind later is not a schema migration; only the time forms and manual are implemented today.

func (Trigger) Validate

func (tr Trigger) Validate(now time.Time) error

Validate checks one trigger. The time forms are delegated to the gateway's shared spec validation rather than reparsed here: one validator means a cadence that parses on this surface parses on the scheduler, and a second parser is exactly how the previous two schedulers drifted apart.

type Verify

type Verify struct {
	Commands []string `yaml:"commands,omitempty" json:"commands,omitempty"`

	// Across are checks that run ONCE, after every project has done its share,
	// to test that the projects still agree with each other. Per-project checks
	// cannot answer that: each side can be internally perfect and the pair
	// still inconsistent, which on a cross-cutting change is precisely the
	// failure worth catching. They run in the anchor project's working copy
	// with MEMCODE_TASK_PROJECTS listing every project's working copy.
	Across []string `yaml:"across,omitempty" json:"across,omitempty"`
}

Verify is how a run proves it succeeded. Without it "the agent stopped" gets mistaken for "the task worked", which is how autonomous systems quietly rot.

type Webhook

type Webhook struct {
	Secret string `yaml:"secret,omitempty" json:"secret,omitempty"`
}

Jump to

Keyboard shortcuts

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