orchestrator

package
v0.0.7 Latest Latest
Warning

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

Go to latest
Published: Jun 25, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Index

Constants

View Source
const (
	OpBoidJobDone    = "job_done"
	OpBoidJobList    = "job_list"
	OpBoidJobShow    = "job_show"
	OpBoidJobLog     = "job_log"
	OpBoidActionSend = "action_send"
	OpBoidAgentStop  = "agent_stop"
	OpBoidTaskCreate = "task_create"
	OpBoidTaskGet    = "task_get"
	OpBoidTaskUpdate = "task_update"
	OpBoidTaskImport = "task_import"
	OpBoidTaskReopen = "task.reopen"
	OpBoidTaskList   = "task_list"
	OpBoidTaskNotify = "task_notify"
	OpBoidTaskAnswer = "task_answer"
	OpBoidTaskAsk    = "task_ask"
	OpBoidTaskDelete = "task_delete"

	OpGitFetch      = "fetch"
	OpGitPush       = "push"
	OpGitPushDelete = "push_delete"
	OpGitCloneLocal = "clone_local"

	OpFetchGet = "get"
)

Mirror of sandbox layer's op constants. orchestrator cannot import sandbox (that would reverse the layer direction), so these are kept as string literals that must stay in lock-step with internal/sandbox/protocol.go. Drift is detected by internal/dispatcher/policy_translate_test.go — the only layer allowed to see both sides.

View Source
const DefaultBehavior = "supervisor"

DefaultBehavior is the hardcoded fallback behavior name used when a CreateTaskRequest omits both behavior and behavior_spec, the project meta is nil, and no default_task_behavior is configured. When meta is non-nil, the default resolution consults meta.DefaultTaskBehavior first, then falls back to "supervisor" with a deprecation warning if that behavior exists.

View Source
const ParentIDSentinelRoot = "-"

ParentIDSentinelRoot is a sentinel value for CreateTaskRequest.ParentID that explicitly requests root-task creation. When this value is detected at an entry point (sandbox, CLI, HTTP API), auto-populate is skipped and the stored parent_id is left empty. Use this when a root task must be created from inside a child context where auto-populate would otherwise attach the current task as parent.

Variables

View Source
var BehaviorAliases = map[string]string{
	"plan": "supervisor",
	"dev":  "executor",
}

BehaviorAliases maps legacy behavior names to their canonical counterparts. This is the alias table used during the "task_behavior simplification" rename: project.yaml files written before the rename keep using "plan" / "dev"; on load they are normalized to "supervisor" / "executor" and a deprecation warning is emitted. The map is intentionally not exported as mutable state — callers should go through CanonicalBehaviorName.

View Source
var ErrDetachedHead = fmt.Errorf("project is in detached HEAD state")

ErrDetachedHead is returned by ClassifyBaseBranch when the project working directory is in detached-HEAD state. Wrapped so callers can use errors.Is.

Functions

func CanonicalBehaviorName

func CanonicalBehaviorName(name string) (canonical string, isAlias bool)

CanonicalBehaviorName returns the canonical behavior name for the given (possibly aliased) name. If the input is a deprecated alias, the returned canonical name and isAlias=true are returned. Otherwise the input is returned unchanged with isAlias=false.

func ClearPendingAnswer

func ClearPendingAnswer(payload json.RawMessage) json.RawMessage

ClearPendingAnswer removes the pending_answer field from the awaiting trait while preserving session_id, question, and question_id. This is called after a hook is spawned so the answer is not consumed again on the next resume. Returns payload unchanged when the awaiting trait is absent or has no answer.

func ComputeForkPoint

func ComputeForkPoint(parent *Task) string

ComputeForkPoint returns the branch a child task should fork its worktree from, given the parent task. It differs from ComputeHeadBranch in that worktree-less parents (Worktree == false) return their BaseBranch rather than the synthetic "boid/<id8>" branch — because a worktree=false parent runs in the host project dir on its base_branch and never creates a boid/<id8> branch. Falling back to base_branch is required for the Phase 2-2 supervisor case 1 path (host HEAD already on base_branch); otherwise child worktree creation fails with "fork point boid/<id8> not found locally (parent task worktree missing?)".

func ComputeHeadBranch

func ComputeHeadBranch(task *Task) string

ComputeHeadBranch returns the HEAD branch that the given task will occupy when its worktree is created.

Root tasks (ParentID == "") occupy the project's base_branch directly. Child tasks (ParentID != "") get an isolated "boid/<id8>" branch derived from the first 8 characters of their task ID.

Used by BranchLockManager to derive the compound lock key so that tasks on the same HEAD branch are serialized while tasks on different branches run in parallel. For the worktree resolver's fork-point computation (which has different semantics for worktree-less parents), use ComputeForkPoint.

func CreateAction

func CreateAction(dbtx db.DBTX, a *Action) error

func CreateProject

func CreateProject(dbtx db.DBTX, project *Project) error

CreateProject inserts a new project record.

func CreateTask

func CreateTask(dbtx db.DBTX, t *Task) error

func DefaultBuiltinPolicies

func DefaultBuiltinPolicies(role Role, names []string, pctx PolicyContext) map[string]BuiltinPolicy

DefaultBuiltinPolicies creates per-command BuiltinPolicy values for the given role and command names. "boid" and "git" are always available as builtins; pass them explicitly via names.

func DeleteProject

func DeleteProject(dbtx db.DBTX, id string) error

DeleteProject removes a project by ID. All tasks (and their dependent records) belonging to the project are deleted first.

func DeleteTask

func DeleteTask(dbtx db.DBTX, id string) error

func ExpandBaseBranch

func ExpandBaseBranch(value, workDir string) (string, error)

ExpandBaseBranch expands ${current_branch} (or $current_branch) in value by reading the HEAD branch of the git repo at workDir. Unknown variables are left as-is for backward compatibility. Returns an error if the git command fails or the repo is in detached HEAD state.

func ExpandTaskBaseBranch

func ExpandTaskBaseBranch(template, remoteID string) (string, error)

ExpandTaskBaseBranch expands the ${TASK_REMOTE_ID} template variable in template against the supplied remoteID. Other template variables are rejected with one specific carve-out: ${current_branch} (the variable owned by ExpandBaseBranch) is preserved as-is so callers can compose the two expanders without ordering surprises.

Contract:

  • Static values (no ${...} or $... markers, or only ${current_branch}) pass through unchanged. An empty remoteID is fine in that case.
  • If the template references ${TASK_REMOTE_ID} but remoteID is empty, expansion fails: task creation must not silently produce a broken branch name. Callers are expected to surface this as a 400.
  • Any other ${VAR} or $VAR reference is rejected. This intentionally restricts the surface; the longer-term plan is to keep template variables to an explicit allow-list rather than letting arbitrary env vars leak into task state.

func FilterPayloadByTraits

func FilterPayloadByTraits(payload json.RawMessage, consumes []TraitType) json.RawMessage

FilterPayloadByTraits returns a payload containing only the top-level keys matching the listed traits. If consumes is empty, an empty payload is returned.

func IsBehaviorAliasKey

func IsBehaviorAliasKey(key string) bool

IsBehaviorAliasKey reports whether the given key is a deprecated alias key. Display code that needs to suppress mirror entries for the migration period (so user-facing output does not show the same behavior twice) can skip keys where IsBehaviorAliasKey returns true and the canonical counterpart is also present in the same map.

func IsInstructionsEditable

func IsInstructionsEditable(status TaskStatus) bool

IsInstructionsEditable reports whether a task's instructions can be edited in the given status. Editing is only allowed while the task is pending to avoid racing with in-flight handlers and to prevent post-execution mutations.

func IsProjectScopable

func IsProjectScopable(km *KitMeta) error

IsProjectScopable reports whether a kit may be placed in the top-level project.yaml kits field. A kit is project-scopable when all its hooks have kind == "agent" (opt-in via instructions, so they cannot fire unexpectedly across behaviors).

func IsReadonly

func IsReadonly(task *Task) bool

IsReadonly returns true if the task's working directory should be mounted read-only. Driven solely by the task.readonly flag (e.g. plan tasks).

func MarshalProjectLocalMeta

func MarshalProjectLocalMeta(meta *ProjectLocalMeta) ([]byte, error)

func MergeKitMetaIntoBehavior

func MergeKitMetaIntoBehavior(behavior *TaskBehavior, kits []*KitMeta, kitAgents []string) error

MergeKitMetaIntoBehavior merges kit-provided hooks, env, bindings, and host_commands into the given TaskBehavior. Kit hook IDs are prefixed with the agent name. The behavior is modified in place.

func MergePayload

func MergePayload(base, update json.RawMessage) (json.RawMessage, error)

func MergePayloadPatch

func MergePayloadPatch(base, patch json.RawMessage, handlerID string, allowedTraits []TraitType) (json.RawMessage, error)

func ProjectLocalPath

func ProjectLocalPath(dir string) string

func RejectPayloadInstructions

func RejectPayloadInstructions(payload json.RawMessage) error

RejectPayloadInstructions returns an error if payload contains an "instructions" top-level key. instructions moved out of payload into Task.Instructions; accepting it here would silently drop it.

func RejectReservedPayloadKeys

func RejectReservedPayloadKeys(payload json.RawMessage) error

RejectReservedPayloadKeys returns an error if the payload contains writes to the artifact.children.* namespace (which is managed by virtual evaluation only).

func RepoRefToCloneURL

func RepoRefToCloneURL(repoRef string, useSSH bool) string

RepoRefToCloneURL converts a repo reference to a git clone URL. When useSSH is true, it returns an SSH URL (git@host:owner/repo.git). Otherwise it returns an HTTPS URL (https://host/owner/repo.git).

func RepoRefsFromKitRefs

func RepoRefsFromKitRefs(kitRefs []string) []string

RepoRefsFromKitRefs extracts unique repo references (host/owner/repo) from a list of kit refs. Only remote refs with 4+ path segments are included. Local refs (< 4 segments) and "local/" prefix refs are skipped.

func ResolveHookScript

func ResolveHookScript(hooksDir, hookID string) (string, error)

func ResolveKitAgent

func ResolveKitAgent(ref KitRef) string

ResolveKitAgent derives the agent name for a kit reference. If an alias is set via 'as:', that alias is returned. Otherwise the last path segment of the ref is used.

func SetPendingAnswer added in v0.0.7

func SetPendingAnswer(payload json.RawMessage, answer string) json.RawMessage

SetPendingAnswer writes answer into the awaiting trait's pending_answer field while preserving question/question_id and any sibling traits. It is the durable counterpart to the in-memory BlockingAskRegistry delivery: when the answering agent has disconnected (its `boid task ask` foreground command was killed by a harness command-timeout), the answer is parked here so the agent picks it up on its next ask. If the awaiting trait is absent, one is created holding just the answer so it is never silently dropped.

func SetProjectWorkspace

func SetProjectWorkspace(dbtx db.DBTX, projectID, workspaceID string) error

SetProjectWorkspace updates a project's local workspace membership.

func StripAwaitingTrait

func StripAwaitingTrait(payload json.RawMessage) json.RawMessage

StripAwaitingTrait removes the entire awaiting trait from a payload. The awaiting trait is owned exclusively by ApplyAction("ask"/"answer") and the lifecycle, so any value carried in a coordinator's FinalPayload (which snapshots task.Payload before the hook runs) is necessarily stale and must not be merged back over the freshly-persisted DB state on hook completion. Returns the payload unchanged when the awaiting trait is absent.

func TraitBool

func TraitBool(payload json.RawMessage, trait string) bool

TraitBool returns true if the given trait key exists and its value is the JSON literal true. The key may be a dot-separated path (e.g. "lifecycle.executed") for nested object access.

func TraitExists

func TraitExists(payload json.RawMessage, trait string) bool

TraitExists reports whether the dotted-path key resolves to a present, non-null JSON value in payload. Unlike TraitBool it does not require the value to be the JSON literal true; any concrete value (object, array, string, number, true) counts as "exists". Used by state-machine condition rules that gate on the presence of a sibling trait (e.g. `lifecycle.done` set by `done_request`) regardless of its body shape.

func TraitGetString

func TraitGetString(payload json.RawMessage, trait string) (string, bool)

TraitGetString reads the string value at a dotted path. Returns ("", false) if the path is missing or the value is not a JSON string. Used by auto-rule ActionPayloadFns to extract user-facing message text from lifecycle traits.

func UpdateTask

func UpdateTask(dbtx db.DBTX, t *Task) error

func ValidatePayloadPatch

func ValidatePayloadPatch(patch json.RawMessage, allowedTraits []TraitType) error

func WriteProjectLocalMeta

func WriteProjectLocalMeta(dir string, meta *ProjectLocalMeta) error

Types

type AbortReason

type AbortReason struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

AbortReason holds metadata derived from the aborted-state transition action.

type Action

type Action struct {
	ID         string          `json:"id"`
	TaskID     string          `json:"task_id"`
	Type       string          `json:"type"`
	Payload    json.RawMessage `json:"payload"`
	FromStatus TaskStatus      `json:"from_status,omitempty"`
	ToStatus   TaskStatus      `json:"to_status,omitempty"`
	CreatedAt  time.Time       `json:"created_at"`
}

func ListActionsByTask

func ListActionsByTask(dbtx db.DBTX, taskID string) ([]*Action, error)

type AdvanceOutcome

type AdvanceOutcome struct {
	Task          *Task
	ActionPayload json.RawMessage // nil unless the fired rule has ActionPayloadFn
}

AdvanceOutcome carries the result of a successful condition-based transition.

type AwaitingPayload

type AwaitingPayload struct {
	Question      string `json:"question,omitempty"`
	QuestionID    string `json:"question_id,omitempty"`
	PendingAnswer string `json:"pending_answer,omitempty"`
}

AwaitingPayload holds the fields of the "awaiting" trait in task.Payload.

The Q&A flow is now uniformly the blocking RPC `boid task ask`: the agent stays alive inside a broker connection while the daemon waits for a user answer, which is then handed back over the same socket via the in-memory BlockingAskRegistry. There is no session-resume path: the legacy `notify --ask` → `reopen -m` round trip is no longer wired to claude `--resume`, and every dispatch starts a fresh agent process.

Fields written when an `ask` action lands:

  • Question: human-readable question text shown to the user
  • QuestionID: UUID identifying this Q&A turn (for multi-turn tracking)

Fields written by boid (set when the user submits an answer):

  • PendingAnswer: the user's reply (legacy field; the blocking RPC delivers answers in-memory and never sets this, but legacy `notify --ask` paths still surface it as $BOID_USER_ANSWER on the next hook invocation)

SessionID/Mode/Source have been removed: the harness-resume mode they described is gone, and persisted records with those fields deserialize cleanly (encoding/json ignores unknown keys).

func GetAwaitingPayload

func GetAwaitingPayload(payload json.RawMessage) AwaitingPayload

GetAwaitingPayload reads the awaiting trait from raw payload. Returns an empty struct if the trait is absent or malformed.

type BaseBranchState

type BaseBranchState int

BaseBranchState describes the relationship between the project's working directory HEAD and a task's resolved base_branch. The three cases drive Phase 2-2 supervisor execution location routing:

  • Case1HeadMatches: project dir is already on baseBranch → supervisor runs in project dir directly (worktree=false).
  • Case2ExistsButNotCheckedOut: baseBranch exists locally or on origin but project HEAD is on a different branch → supervisor needs a worktree that checks baseBranch out.
  • Case3NotFound: baseBranch is unknown to both local and origin → supervisor needs a worktree whose base branch must be created from the current project HEAD before the worktree can be allocated.

Detached-HEAD projects cannot serve as a sensible default for any of these cases (case 3 in particular needs a real branch to derive from), so classification reports the dedicated detached-HEAD error rather than guessing.

const (
	// BaseBranchStateUnknown is the zero value. It only appears when an error
	// is returned; callers should never act on it directly.
	BaseBranchStateUnknown BaseBranchState = iota
	// Case1HeadMatches: project HEAD == baseBranch. Worktree is unnecessary;
	// the supervisor can run directly in the project dir.
	Case1HeadMatches
	// Case2ExistsButNotCheckedOut: baseBranch resolves to a ref (local or
	// origin/<baseBranch>) but is not what the project HEAD points at.
	Case2ExistsButNotCheckedOut
	// Case3NotFound: baseBranch does not exist locally and origin/<baseBranch>
	// is unknown to the project repo. Callers that own the project dir create
	// the branch from HEAD before allocating a worktree.
	Case3NotFound
)

func ClassifyBaseBranch

func ClassifyBaseBranch(projectDir, baseBranch string) (BaseBranchState, error)

ClassifyBaseBranch inspects the git repository at projectDir to decide how a task whose resolved base_branch is baseBranch should be scheduled. See BaseBranchState for the three case definitions.

baseBranch must already be expanded (no ${...} templates) and non-empty. P1: empty baseBranch is rejected — the service layer must resolve ${current_branch} before calling ClassifyBaseBranch.

The classify call is read-only: no branches are created, no fetches are issued, and the project HEAD is not mutated. Case 3 mitigation (creating the branch) is the caller's responsibility; ClassifyBaseBranch only reports the state.

func (BaseBranchState) String

func (s BaseBranchState) String() string

String makes BaseBranchState printable for log lines / test failure messages. Not part of the public contract — callers should switch on the enum value, not on the rendered string.

type BehaviorResolution

type BehaviorResolution struct {
	BehaviorName string
	Traits       []string
	Readonly     bool
	Worktree     bool
	BranchPrefix string
	BaseBranch   string
	Payload      json.RawMessage
	Instructions Instructions
}

BehaviorResolution holds the resolved behavior fields after processing either a named behavior or an inline behavior_spec.

func ResolveBehavior

func ResolveBehavior(meta *ProjectMeta, req BehaviorResolveRequest) (*BehaviorResolution, error)

ResolveBehavior validates and resolves behavior fields from a task creation request. It handles both the named behavior path (meta lookup) and the inline behavior_spec path. When both behavior and behavior_spec are empty, the default is resolved via:

  1. meta.DefaultTaskBehavior if set
  2. implicit "supervisor" fallback if that behavior exists in meta (with WARN)
  3. error if neither is available
  4. hardcoded DefaultBehavior when meta is nil (nil-meta paths, e.g. test wiring)

type BehaviorResolveRequest

type BehaviorResolveRequest struct {
	Behavior     string
	BehaviorSpec *BehaviorSpec
	Payload      json.RawMessage
	Instructions json.RawMessage
}

BehaviorResolveRequest carries the behavior-relevant fields from a task creation request.

type BehaviorSpec

type BehaviorSpec struct {
	Name               string       `yaml:"name" json:"name"`
	Traits             []string     `yaml:"traits,omitempty" json:"traits,omitempty"`
	DefaultInstruction *Instruction `yaml:"default_instruction,omitempty" json:"default_instruction,omitempty"`
}

BehaviorSpec is an inline behavior specification that can be used instead of referencing a named behavior from project.yaml task_behaviors. This allows kits to self-describe the behavior they need without depending on project config.

type BindMount

type BindMount struct {
	Source   string `yaml:"source" json:"source"`
	Target   string `yaml:"target,omitempty" json:"target,omitempty"` // if empty, defaults to Source
	Mode     string `yaml:"mode" json:"mode"`                         // "rw" | "" (ro default)
	IsFile   bool   `yaml:"is_file,omitempty" json:"is_file,omitempty"`
	Optional bool   `yaml:"optional,omitempty" json:"optional,omitempty"` // if true, skip mount when Source does not exist on the host
}

BindMount is a plain shared DTO across orchestration and sandbox planning. It carries mount source/target/mode data and does not encode provider behavior.

type BranchLockManager

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

BranchLockManager keeps exclusive access to a branch (identified by the compound key "<projectID>:<headBranch>") for the entire executing lifetime of a single task.

Unlike the underlying WorktreeLocker (which returns an opaque release closure per acquire), BranchLockManager keys the held lock by task id so that Acquire and Release can be called from different goroutines and at different points in the task lifecycle (Acquire when the task enters executing, Release when it leaves).

Calls are idempotent for the same task id: re-Acquire on a task that already holds the lock for the same branch is a no-op, and Release on an unheld task is a no-op. This matches the realistic dispatch flow where a task may go through multiple dispatch cycles inside a single executing window.

BranchLockManager is safe for concurrent use.

func NewBranchLockManager

func NewBranchLockManager(underlying WorktreeLocker) *BranchLockManager

NewBranchLockManager wraps the given WorktreeLocker so the lock can be pinned to a task id rather than a single Acquire call. The underlying locker is responsible for the actual mutual exclusion semantics (FIFO queueing, context cancellation, etc.).

func (*BranchLockManager) AcquireForTask

func (m *BranchLockManager) AcquireForTask(ctx context.Context, projectID, headBranch, taskID string) error

AcquireForTask acquires the branch lock keyed by "<projectID>:<headBranch>" on behalf of the given task. If the task already holds the lock for the same key, this is a no-op (no double-acquire). Otherwise it blocks on the underlying locker until either the lock is acquired or ctx is cancelled.

func (*BranchLockManager) IsHeldForTask

func (m *BranchLockManager) IsHeldForTask(taskID string) bool

IsHeldForTask reports whether the given task currently holds a branch lock. Intended for tests and diagnostics.

func (*BranchLockManager) ReleaseForTask

func (m *BranchLockManager) ReleaseForTask(taskID string)

ReleaseForTask releases the branch lock held on behalf of the task. No-op when the task does not currently hold a lock — the goal is to make "release on every executing-leaving path" cheap to wire up.

type BuiltinPolicy

type BuiltinPolicy struct {
	AllowedOps      []string
	AllowedCwdRoots []string
}

BuiltinPolicy is the orchestrator-owned, sandbox-agnostic policy type. dispatcher is responsible for translating this into sandbox.BuiltinPolicy before it reaches the broker.

AllowedOps is a sorted slice (rather than a set) so the value is trivially comparable and serialisable across the orchestrator/dispatcher boundary.

func (BuiltinPolicy) Allows

func (p BuiltinPolicy) Allows(op string) bool

Allows reports whether op is in the allowed set.

func (BuiltinPolicy) AllowsCwd

func (p BuiltinPolicy) AllowsCwd(cwd string) bool

AllowsCwd reports whether cwd is within any of the policy's additional cwd roots.

type Capabilities added in v0.0.2

type Capabilities struct {
	// Docker, when non-nil, enables the per-sandbox native docker proxy.
	// Declared as capabilities.docker: {} in project.yaml.
	Docker *DockerCapability `yaml:"docker,omitempty" json:"docker,omitempty"`
}

Capabilities declares optional sandbox capabilities declared in project.yaml.

type CleanupFunc

type CleanupFunc func()

CleanupFunc releases transient resources created while planning a JobSpec (e.g. staging directories for hook scripts). dispatcher invokes it after the sandbox process has exited. A nil CleanupFunc means nothing to release.

type CommandDef

type CommandDef struct {
	Name               string            `json:"name,omitempty"`
	Path               string            `json:"path,omitempty"`
	AllowedPatterns    []string          `json:"allowed_patterns,omitempty"`
	DeniedPatterns     []string          `json:"denied_patterns,omitempty"`
	AllowedSubcommands []string          `json:"allowed_subcommands,omitempty"`
	AllowStdin         bool              `json:"allow_stdin,omitempty"`
	Env                map[string]string `json:"env,omitempty"`
}

CommandDef is the orchestrator-side transport shape for sandbox command policy input. Dispatcher and sandbox mirror this shape; sandbox owns the enforcement semantics.

type Coordinator

type Coordinator struct {
	Evaluator      *Evaluator
	HookExecutor   HookExecutor
	Waiter         JobWaiter
	MaxDepth       int
	LifecycleStore LifecycleStore // optional; nil skips rework_count/abort derivation
}

Coordinator orchestrates the hook → advance flow.

Locking is owned by the workflow service for the entire executing lifetime of each task (see internal/api/service.go). The coordinator dispatches hooks under the assumption that the branch lock is already held by the caller.

func (*Coordinator) DispatchAndAdvance

func (d *Coordinator) DispatchAndAdvance(
	ctx context.Context,
	task *Task,
	meta *ProjectMeta,
	sm *StateMachine,
) (*DispatchResult, error)

DispatchAndAdvance runs the full dispatch cycle: 1. Evaluate and execute hooks (parallel if readonly, sequential otherwise) 2. Merge hook payload patches 3. Evaluate condition-based auto-advance via state machine

func (*Coordinator) ReplayHook

func (d *Coordinator) ReplayHook(
	ctx context.Context,
	task *Task,
	meta *ProjectMeta,
	sm *StateMachine,
	hookID string,
) (*ReplayResult, error)

ReplayHook executes a single named hook in isolation against the task's current state. After the hook completes, lifecycle is derived and sm.Advance is applied — identical to the post-hook phase of DispatchAndAdvance.

Returns an error if the hook ID is not found in the behavior or if the hook does not match the current status (e.g. task not in executing state).

type DBProjectCatalog

type DBProjectCatalog struct {
	DB db.DBTX
}

func (DBProjectCatalog) GetProject

func (c DBProjectCatalog) GetProject(id string) (*Project, error)

func (DBProjectCatalog) ListProjects

func (c DBProjectCatalog) ListProjects() ([]*Project, error)

type DBTaskLookup

type DBTaskLookup struct {
	DB db.DBTX
}

func (DBTaskLookup) GetTask

func (l DBTaskLookup) GetTask(id string) (*Task, error)

type DispatchPlanner

type DispatchPlanner struct {
	Meta     MetaCache
	Projects ProjectCatalog
	Tasks    TaskLookup
	Adapter  adapters.HarnessAdapter
}

DispatchPlanner turns state-machine-driven hook fire events into a sandbox-agnostic JobSpec. All sandbox construction concerns (mounts, env, proxy wiring, exit scripts, worktree recreation) live in dispatcher.

func WireDispatchPlanner

func WireDispatchPlanner(cfg PlannerWireConfig) *DispatchPlanner

func (*DispatchPlanner) PlanHook

func (p *DispatchPlanner) PlanHook(event *HookFireEvent) (*JobSpec, CleanupFunc, error)

PlanHook renders a hook fire event into a JobSpec.

Agent-kind hooks (Hook.Kind == HandlerKindAgent) may omit ScriptPath — the HarnessAdapter builds its own argv from CLI conventions, so an empty Argv flows through fine. Non-agent hooks (shell-bound) still require a resolved script path. The Evaluator may synthesize a script-less agent hook when the behavior declares none of its own (Phase 3-e kit-retirement fallback); the relaxed validation here is what makes those virtual hooks dispatch-ready.

type DispatchResult

type DispatchResult struct {
	Results       []HandlerResult
	FiredEvents   []FiredEvent
	FinalPayload  json.RawMessage
	NewStatus     TaskStatus      // set if orchestrator advanced the state
	ActionPayload json.RawMessage // optional payload to attach to the auto_advance action
}

DispatchResult is the accumulated result of a full dispatch cycle.

type DockerCapability added in v0.0.2

type DockerCapability struct{}

DockerCapability is the opt-in marker for the native docker proxy. Presence (non-nil pointer in Capabilities) enables the proxy; the empty struct is a placeholder for future per-project policy fields.

type DoneReport

type DoneReport struct {
	Message string `json:"message"`
}

DoneReport carries the message the agent reported via `notify --done`.

type Evaluator

type Evaluator struct{}

func (*Evaluator) Evaluate

func (e *Evaluator) Evaluate(task *Task, hooks []Hook) []Hook

Evaluate returns hooks that should fire for the given task. Hooks fire only during executing state. Hooks with Kind == HandlerKindAgent additionally require an instruction in task.Instructions addressed to that hook's Agent.

Phase 3-e fallback: when the behavior declares no agent-kind hook at all (typical after the boid-kits claude-code/codex retirement landed in PR #604), the evaluator synthesizes a virtual agent-kind hook for the active instruction's agent. The runner-inner-child hands every agent-kind job to its HarnessAdapter directly, so a hook with no ScriptPath is dispatch-ready — see planner.PlanHook and adapters.HarnessAdapter.Run. The synthesis is gated to known harness agents (claude-code / codex / opencode) so unknown agent names do not collide with the shell adapter's Argv requirement.

type FailReport

type FailReport struct {
	Message string `json:"message"`
}

FailReport carries the message the agent reported via `notify --fail`.

type FiredEvent

type FiredEvent struct {
	KitID       string // kit that owns this handler; empty for project-local
	HandlerID   string // hook ID
	JobID       string // ID of the job that executed this handler
	Kind        string // "hook" or "hook_replay"
	SourceState string // task status at the time of dispatch
	Success     bool
	Error       string
}

FiredEvent records a single hook execution for action logging.

type GCLoop

type GCLoop struct {
	Store        GCStore
	Interval     time.Duration
	OlderThan    time.Duration
	InitialDelay time.Duration
}

GCLoop periodically calls GC on a GCStore.

func (*GCLoop) Run

func (l *GCLoop) Run(ctx context.Context)

Run blocks until ctx is done. It waits InitialDelay before the first GC run, then calls Store.GC every Interval. Errors are logged as warnings; the loop always continues.

type GCResult

type GCResult struct {
	Tasks      int64
	Jobs       int64
	Actions    int64
	Worktrees  int64
	Runtimes   int64
	SandboxTmp int64 // leaked /tmp/boid-* sandbox artifacts removed
	Devices    int64 // revoked web devices deleted
}

GCResult holds the count of records affected by GC.

func GCTasks

func GCTasks(dbtx db.DBTX, statuses []string, olderThan time.Duration, dryRun bool) (*GCResult, error)

GCTasks deletes terminal tasks older than olderThan and their related data (actions, jobs, worktrees). If dryRun is true, counts only without deleting. olderThan=0 disables the time filter (all matching tasks are affected). Must be called within a transaction for atomicity.

type GCStore

type GCStore interface {
	GC(olderThan time.Duration, dryRun bool) (*GCResult, error)
}

GCStore is the interface required by GCLoop to run garbage collection.

type HandlerKind

type HandlerKind string

HandlerKind distinguishes the role a hook plays. An empty kind means a generic hook (no instructions routing). Only agent-kind hooks participate in instructions routing.

const (
	HandlerKindAgent HandlerKind = "agent"
)

func (HandlerKind) IsValid

func (k HandlerKind) IsValid() bool

IsValid reports whether the kind value is recognized.

type HandlerResult

type HandlerResult struct {
	ID           string // hook ID
	Role         Role
	JobID        string // ID of the job that executed this handler
	ExitCode     int
	PayloadPatch json.RawMessage
}

HandlerResult is the result of a single hook execution.

type HandlerTraits

type HandlerTraits struct {
	Consumes []TraitType `json:"consumes,omitempty" yaml:"consumes,omitempty"`
	Produces []TraitType `json:"produces,omitempty" yaml:"produces,omitempty"`
}

type Hook

type Hook struct {
	ID         string        `yaml:"id" json:"id"`
	Name       string        `yaml:"name,omitempty" json:"name,omitempty"`
	Kind       HandlerKind   `yaml:"kind,omitempty" json:"kind,omitempty"`
	Traits     HandlerTraits `yaml:"traits" json:"traits"`
	Requires   []string      `yaml:"requires" json:"requires"`
	Agent      string        `yaml:"agent,omitempty" json:"agent,omitempty"`
	Kit        string        `yaml:"-" json:"kit,omitempty"`
	ScriptPath string        `yaml:"-" json:"-"`
}

func ListHooksForStatus

func ListHooksForStatus(meta *ProjectMeta, task *Task, status TaskStatus) []Hook

ListHooksForStatus returns hooks from the behavior that would match the given status. Since hooks only fire during executing, only executing status yields results. Used by "boid task hook list".

func (*Hook) UnmarshalYAML

func (h *Hook) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML rejects legacy `on:` entries to surface migration breakage clearly.

type HookExecutor

type HookExecutor interface {
	ExecuteHook(ctx context.Context, event *HookFireEvent) (jobID string, err error)
}

HookExecutor launches a hook and returns the job ID.

type HookFile

type HookFile struct {
	Source     string // host-side absolute path
	TargetName string // filename inside sandbox .boid/hooks/
}

HookFile describes a single hook file to bind-mount into the sandbox.

type HookFireEvent

type HookFireEvent struct {
	EventID   string
	TaskID    string
	ProjectID string
	Hook      Hook
}

type HostCommandSpec

type HostCommandSpec struct {
	Allow []string          `yaml:"allow,omitempty" json:"allow,omitempty"`
	Deny  []string          `yaml:"deny,omitempty" json:"deny,omitempty"`
	Stdin bool              `yaml:"stdin,omitempty" json:"stdin,omitempty"`
	Path  string            `yaml:"path,omitempty" json:"path,omitempty"`
	Env   map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
}

HostCommandSpec is the simplified YAML DSL for declaring host commands.

func (HostCommandSpec) ToCommandDef

func (s HostCommandSpec) ToCommandDef(name string) CommandDef

ToCommandDef converts a HostCommandSpec into a CommandDef for internal use.

type HostCommands

type HostCommands map[string]HostCommandSpec

HostCommands supports both list and map YAML forms:

host_commands: [gh, aws]
host_commands:
  gh:
    allow: [pr, issue]
  aws:

func (HostCommands) ToCommandDefs

func (h HostCommands) ToCommandDefs() map[string]CommandDef

ToCommandDefs converts the DSL specs to internal CommandDef map.

func (*HostCommands) UnmarshalYAML

func (h *HostCommands) UnmarshalYAML(value *yaml.Node) error

type InMemoryWorktreeLockManager

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

InMemoryWorktreeLockManager implements WorktreeLocker with per-key FIFO queueing.

func NewInMemoryWorktreeLockManager

func NewInMemoryWorktreeLockManager() *InMemoryWorktreeLockManager

NewInMemoryWorktreeLockManager creates a new InMemoryWorktreeLockManager.

func (*InMemoryWorktreeLockManager) Acquire

func (m *InMemoryWorktreeLockManager) Acquire(ctx context.Context, key string) (func(), error)

type Instruction

type Instruction struct {
	Agent   string `json:"agent" yaml:"agent"`
	Name    string `json:"name,omitempty" yaml:"name,omitempty"`
	Message string `json:"message,omitempty" yaml:"message,omitempty"`
	Model   string `json:"model,omitempty" yaml:"model,omitempty"`
}

type Instructions

type Instructions []Instruction

Instructions is the persisted instruction history for a task. The most recent entry is the "active" instruction passed to the agent on dispatch; earlier entries are kept as history (e.g. for reopen tracking).

JSON shape on the wire is an array. For backward compatibility, the legacy single-instruction map form ({"main": {...}}) is also accepted on unmarshal and converted to a single-element array.

func AppendInstruction

func AppendInstruction(existing Instructions, inst Instruction) Instructions

AppendInstruction returns a new instruction list with `inst` appended. The caller is responsible for filling in fields not derived from the previous active entry. Used by `boid task reopen` to record a new context message while preserving history.

func MergeDefaultInstructions

func MergeDefaultInstructions(defaultInstruction *Instruction, requestInstructions json.RawMessage) (Instructions, error)

MergeDefaultInstructions builds the initial instruction list for a new task.

Merge semantics:

  • defaultInstruction == nil: use requestInstructions as-is (no base to inherit from).
  • defaultInstruction != nil:
  • override empty/null/[]/{} → return the default as a single-entry list.
  • override has exactly 1 entry → per-field merge: non-empty fields from the override win; empty fields inherit from defaultInstruction.
  • override has 2+ entries → complete replacement (caller is building an explicit history and partial merge would be ambiguous).

requestInstructions accepts the array form `[{...}, ...]` and the legacy single-map form `{"main": {...}}` (handled by Instructions.UnmarshalJSON).

func (Instructions) Active

func (is Instructions) Active() *Instruction

Active returns the currently-active instruction (the last entry), or nil if the list is empty.

func (*Instructions) UnmarshalJSON

func (is *Instructions) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts both the new array form and the legacy {"main": {...}, "verify": {...}} map form. For the map form, only the "main" entry is preserved (verifying/reworking variants were removed).

type JobCompletion

type JobCompletion struct {
	JobID    string
	Output   string // stdout capture or payload_patch.json content
	ExitCode int
}

JobCompletion represents the result of a completed job.

type JobKind

type JobKind string

JobKind is a DB / observability label for a job. It is NOT a sandbox construction signal — dispatcher's mount/env logic must derive everything from Visibility / HostCommands / Instruction, never from Kind. Kind exists purely so the Job row / TUI can tell apart "agent hook" and "user-initiated exec" without re-deriving the category from loosely-related primitives like HandlerID.

const (
	JobKindHook JobKind = "hook"
	JobKindExec JobKind = "exec"
	// JobKindSession is the Phase 3-d label for a user-initiated agent
	// session not tied to a task lifecycle (WebUI [New Session] / `boid
	// agent` CLI). It is distinct from JobKindHook (state-machine driven)
	// and JobKindExec (sandboxed argv with no harness), so the TUI / Web UI
	// can present it as a separate top-level concept.
	JobKindSession JobKind = "session"
)

type JobSpec

type JobSpec struct {
	// Identity used by dispatcher for Job DB persistence and state-machine
	// notification. TaskID and HandlerID are empty for boid-exec jobs.
	TaskID      string
	ProjectID   string
	HandlerID   string
	DisplayName string // human-readable label (hook name or command-session name); persisted to jobs.display_name

	// Kind is a DB-label / TUI-display category. Sandbox construction details
	// MUST NOT branch on this value.
	Kind JobKind

	// Argv is the command to execute. Argv[0] is either a host absolute path
	// (hook scripts) or a bare command name resolved via broker shim
	// (boid exec). Everything after Argv[0] is passed as-is.
	Argv []string

	// Instruction, when non-nil, materializes at $HOME/.boid/context/instructions.yaml
	// and identifies the agent that should pick up the job. Agent-less jobs
	// (boid exec) leave this nil.
	Instruction *RoutedInstruction

	// Task, when non-nil, materializes at $HOME/.boid/context/task.yaml.
	// boid-exec jobs always leave it nil.
	Task *TaskSnapshot

	// PrimaryInput is the payload the script expects to read on stdin (or,
	// for agent jobs, at $HOME/.boid/context/payload.json).
	// nil means the job reads nothing from boid (e.g. boid-exec with a user TTY).
	PrimaryInput json.RawMessage

	// Visibility describes which host directories the sandbox sees and whether
	// they are writable.
	Visibility Visibility

	// BuiltinPolicies authorises broker-mediated builtin operations (boid, git).
	BuiltinPolicies map[string]BuiltinPolicy

	// HostCommands authorises broker-mediated host command invocations.
	// Hook jobs leave this empty; exec jobs populate it from behavior.
	HostCommands map[string]CommandDef

	// SecretNamespace scopes the broker's secret resolver.
	SecretNamespace string

	// Env carries extra environment variables the orchestrator wants to export
	// (e.g. behavior-level overrides). dispatcher merges these with its own
	// HOME/PATH/proxy/broker settings.
	Env map[string]string

	// ExecutionState records the task.Status at the time this job was dispatched.
	// Stored in the job DB row so TUI can reconstruct replay context.
	ExecutionState string

	// Interactive, when true, forces TTY allocation regardless of whether an
	// Instruction is attached. Used by daemon-side command execution (Web UI)
	// where the caller always expects a PTY-backed terminal.
	Interactive bool

	// HarnessType identifies which HarnessAdapter implementation the runner
	// hands the agent process off to. Phase 3-d made this invariant
	// non-empty for every dispatched job:
	//   - "shell" for hooks without an `agent:` declaration, every `boid
	//     exec`, and the fall-through for unknown agents
	//   - "claude" / "codex" / "opencode" for the corresponding agent hooks
	//     and user-initiated sessions
	// dispatcher bridges this into sandbox.Spec.HarnessType and the
	// runner-inner-child resolves the adapter via the registry.
	HarnessType string
}

JobSpec is the orchestrator-owned, sandbox-agnostic execution request. It is written purely in business vocabulary: "what to run with what visibility and permissions". All sandbox construction details (mounts, env, exit scripts, proxy wiring, stdin routing) are left to dispatcher.

dispatcher is the only layer that bridges JobSpec and sandbox.Spec.

type JobWaiter

type JobWaiter interface {
	WaitForJob(ctx context.Context, jobID string) (JobCompletion, error)
}

JobWaiter waits for a job to complete.

type KitDetect

type KitDetect struct {
	// Script is a path (relative to the kit directory) to a POSIX sh
	// script. boid init runs it with sh(1) using projectDir as CWD and a
	// 5-second timeout. The first trimmed line of stdout is interpreted:
	//   "required" → kit is auto-selected
	//   "optional" → kit is shown as a candidate but not auto-selected
	//   other / empty / non-zero exit → not applicable
	Script string `yaml:"script"`
}

KitDetect declares how to determine whether a kit is applicable to a project. The referenced script is executed with POSIX sh in the project directory; its first line of stdout — "required", "optional", or empty — indicates the detection outcome.

type KitMeta

type KitMeta struct {
	TaskBehaviors      map[string]TaskBehavior `yaml:"task_behaviors"`
	Hooks              []Hook                  `yaml:"hooks"`
	HostCommands       HostCommands            `yaml:"host_commands"`
	AdditionalBindings []BindMount             `yaml:"additional_bindings"`
	Env                map[string]string       `yaml:"env"`
	HooksDir           string                  `yaml:"-"`
	KitRoot            string                  `yaml:"-"` // directory containing kit.yaml

	// Init-time metadata — not merged into runtime spec by MergeKitMeta.
	Meta          *KitMetaInfo `yaml:"meta,omitempty"`
	Detect        *KitDetect   `yaml:"detect,omitempty"`
	Requires      *KitRequires `yaml:"requires,omitempty"`
	ProvidesAgent string       `yaml:"provides_agent,omitempty"`
	Deprecated    bool         `yaml:"deprecated,omitempty"`
}

KitMeta holds the parsed content of a kit.yaml file.

func ReadKitMeta

func ReadKitMeta(dir string) (*KitMeta, error)

type KitMetaInfo

type KitMetaInfo struct {
	Name        string `yaml:"name"`
	Description string `yaml:"description"`
	Category    string `yaml:"category"` // language / vcs / ci / agent / workflow / utility
}

KitMetaInfo holds human-readable metadata for a kit.

type KitRef

type KitRef struct {
	Ref   string `yaml:"ref" json:"ref"`
	Alias string `yaml:"as,omitempty" json:"as,omitempty"`
}

func (*KitRef) UnmarshalYAML

func (k *KitRef) UnmarshalYAML(value *yaml.Node) error

type KitRegistry

type KitRegistry struct {
	BaseDir string // e.g. ~/.local/share/boid/kits
}

KitRegistry manages installed kit repositories under a base directory.

func NewRegistry

func NewRegistry(baseDir string) *KitRegistry

NewRegistry creates a new kit registry with the given base directory.

func (*KitRegistry) Install

func (r *KitRegistry) Install(repoRef string, useSSH bool) error

Install clones a kit repository from its conventional URL. The repoRef should be like "github.com/user/repo". When useSSH is true, the SSH protocol (git@host:path.git) is used.

func (*KitRegistry) InstallFromURL

func (r *KitRegistry) InstallFromURL(repoRef, url string) error

InstallFromURL clones a git repo from the given URL into BaseDir/repoRef.

func (*KitRegistry) IsInstalled

func (r *KitRegistry) IsInstalled(repoRef string) bool

IsInstalled returns true if the repo directory already exists under BaseDir.

func (*KitRegistry) List

func (r *KitRegistry) List() ([]string, error)

List returns all installed kit repository references. It finds directories containing .git under BaseDir.

func (*KitRegistry) Remove

func (r *KitRegistry) Remove(repoRef string) error

Remove deletes an installed kit repository.

func (*KitRegistry) Resolve

func (r *KitRegistry) Resolve(ref string) (string, error)

Resolve returns the absolute filesystem path for a kit reference. A ref like "github.com/user/repo/go" is split into the repo path (first 3 segments) and the kit subpath (remainder).

func (*KitRegistry) Update

func (r *KitRegistry) Update(repoRef string) error

Update runs git pull in an installed kit repository.

type KitRequires

type KitRequires struct {
	Commands []string `yaml:"commands"`
}

KitRequires declares host commands that must be present in PATH.

type KitResolver

type KitResolver interface {
	Resolve(ref string) (string, error)
}

type KitRuntime

type KitRuntime struct {
	AdditionalBindings []BindMount
	HostCommands       HostCommands
	Env                map[string]string
}

KitRuntime holds the merged runtime fields derived from a set of kits. It covers env, host_commands, and additional_bindings. Hooks and directory metadata are excluded — those are TaskBehavior-specific and handled by MergeKitMetaIntoBehavior.

func MergeKitRuntime

func MergeKitRuntime(kits []*KitMeta, kitAgents []string) (KitRuntime, error)

MergeKitRuntime merges env, host_commands, and additional_bindings from the given kits into a KitRuntime value. kitAgents provides display names for error messages (one per kit).

type Lifecycle

type Lifecycle struct {
	Executed bool         `json:"executed"`
	Done     *DoneReport  `json:"done,omitempty"`
	Fail     *FailReport  `json:"fail,omitempty"`
	Abort    *AbortReason `json:"abort,omitempty"`
}

Lifecycle holds computed lifecycle traits derived from action history and the current dispatch state. It is never persisted to the payload; it is injected transiently before state-machine evaluation.

Done / Fail carry the agent's intent reported via `notify --done` / `notify --fail` (recorded as `done_request` / `fail_request` actions). They gate the executing→done / executing→aborted auto-advance rules. By keeping these as derived traits (rather than firing an immediate state transition inside NotifyTask), the runtime is allowed to exit cleanly via SIGUSR1 → bash EXIT trap → `boid job done` before the dispatch loop advances the state. See docs/plans/lifecycle-accountability.md (Phase 2.c follow-up).

func DeriveLifecycle

func DeriveLifecycle(_ context.Context, taskID string, store LifecycleStore, hookExecuted bool) (Lifecycle, error)

DeriveLifecycle computes lifecycle traits from action history. hookExecuted indicates whether a hook job completed successfully in the current dispatch cycle (used to set lifecycle.executed). If store is nil, only Executed is set from hookExecuted.

Done / Fail are scoped to the current executing cycle: every time the task (re-)enters executing (start / answer / reopen) the prior intent is cleared, so a `done_request` recorded before a `reopen` does not bleed into the new cycle. done_request / fail_request are mutually exclusive — recording one clears the other.

type LifecycleStore

type LifecycleStore interface {
	ListActionsByTask(taskID string) ([]*Action, error)
}

LifecycleStore is the minimal interface required to derive lifecycle traits from persistent action history.

type MergeMode

type MergeMode string
const (
	MergeModeExclusive MergeMode = "exclusive"
	MergeModeShared    MergeMode = "shared"
)

func TraitMergeMode

func TraitMergeMode(trait TraitType) MergeMode

type MetaCache

type MetaCache interface {
	Get(id string) (*ProjectMeta, bool)
}

type PlannerWireConfig

type PlannerWireConfig struct {
	Meta     MetaCache
	Projects ProjectCatalog
	Tasks    TaskLookup
	Adapter  adapters.HarnessAdapter
}

type PolicyContext

type PolicyContext struct {
	ProjectDir string
	HomeDir    string
}

PolicyContext carries non-role data needed to compute role-derived policies. ProjectDir lets boid policy accept the host project dir as cwd. HomeDir accepts the sandbox HOME, which is the default WorkDir for hook jobs.

type Project

type Project struct {
	ID          string      `json:"id"`
	WorkspaceID string      `json:"workspace_id"`
	WorkDir     string      `json:"work_dir"`
	Meta        ProjectMeta `json:"meta"`
	CreatedAt   time.Time   `json:"created_at"`
	UpdatedAt   time.Time   `json:"updated_at"`
}

func GetProject

func GetProject(dbtx db.DBTX, id string) (*Project, error)

GetProject retrieves a project by ID.

func ListProjects

func ListProjects(dbtx db.DBTX) ([]*Project, error)

ListProjects returns all projects ordered by creation time.

type ProjectCatalog

type ProjectCatalog interface {
	GetProject(id string) (*Project, error)
}

type ProjectLocalMeta

type ProjectLocalMeta struct {
	Version            int               `yaml:"version"`
	HostCommands       HostCommands      `yaml:"host_commands,omitempty"`
	AdditionalBindings []BindMount       `yaml:"additional_bindings,omitempty"`
	Env                map[string]string `yaml:"env,omitempty"`
	SecretNamespace    string            `yaml:"secret_namespace,omitempty"`
}

func NewProjectLocalMeta

func NewProjectLocalMeta() *ProjectLocalMeta

func ReadProjectLocalMeta

func ReadProjectLocalMeta(dir string) (*ProjectLocalMeta, error)

type ProjectMeta

type ProjectMeta struct {
	ID            string                  `yaml:"id" json:"id"`
	Name          string                  `yaml:"name" json:"name"`
	Kits          []KitRef                `yaml:"kits,omitempty" json:"kits,omitempty"`
	TaskBehaviors map[string]TaskBehavior `yaml:"task_behaviors" json:"task_behaviors"`
	// Worktree controls whether tasks in this project run in a per-task git
	// worktree by default. For the canonical "executor" behavior the value
	// is used as-is; for "supervisor" the worktree decision is governed by
	// the base_branch state classification (see ClassifyBaseBranch). For
	// non-canonical behaviors, this is the worktree flag verbatim.
	Worktree bool `yaml:"worktree,omitempty" json:"worktree,omitempty"`
	// BaseBranch is the default git base branch for worktrees created by
	// tasks in this project. It is resolved at task creation time (with
	// ${TASK_REMOTE_ID} / ${current_branch} expansion) and persisted on
	// each task row.
	BaseBranch string `yaml:"base_branch,omitempty" json:"base_branch,omitempty"`
	// ForkPoint is the git ref used as the start point when creating a
	// base branch that does not yet exist (ClassifyBaseBranch case 3).
	// Accepts any ref that `git rev-parse --verify` resolves (e.g. "main",
	// "origin/main", a tag, or a commit SHA). When empty, the dispatcher
	// falls back to "refs/remotes/origin/HEAD"; if that is also unset the
	// case-3 worktree creation fails. The project root's working-tree HEAD
	// is intentionally never consulted, since it can drift to an
	// unexpected branch between task creation and dispatch.
	ForkPoint          string            `yaml:"fork_point,omitempty" json:"fork_point,omitempty"`
	HostCommands       HostCommands      `yaml:"host_commands" json:"host_commands"`
	AdditionalBindings []BindMount       `yaml:"additional_bindings" json:"additional_bindings"`
	Env                map[string]string `yaml:"env" json:"env"`
	SecretNamespace    string            `yaml:"secret_namespace,omitempty" json:"secret_namespace,omitempty"`
	// Capabilities declares optional sandbox capabilities for jobs in this project.
	Capabilities Capabilities `yaml:"capabilities,omitempty" json:"capabilities,omitempty"`
	// DefaultTaskBehavior names the behavior to use when a CreateTaskRequest
	// omits both behavior and behavior_spec. When empty, the daemon falls back
	// to "supervisor" if that behavior exists (with a deprecation warning);
	// if neither is set, CreateTask returns an error.
	DefaultTaskBehavior string `yaml:"default_task_behavior,omitempty" json:"default_task_behavior,omitempty"`
}

func ReadProjectMeta

func ReadProjectMeta(dir string) (*ProjectMeta, error)

func ReadProjectMetaWithKits

func ReadProjectMetaWithKits(dir string, resolver KitResolver) (*ProjectMeta, error)

ReadProjectMetaWithKits reads project.yaml and project.local.yaml, resolves kits referenced by each task behavior, and merges kit data into each behavior. Returns a ProjectMeta whose TaskBehaviors have their resolved Hooks/etc. populated and ready for dispatch.

type ProjectRepository

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

func NewProjectRepository

func NewProjectRepository(db db.DBTX) *ProjectRepository

func (*ProjectRepository) CreateProject

func (r *ProjectRepository) CreateProject(project *Project) error

func (*ProjectRepository) DeleteProject

func (r *ProjectRepository) DeleteProject(id string) error

func (*ProjectRepository) GetProject

func (r *ProjectRepository) GetProject(id string) (*Project, error)

func (*ProjectRepository) ListProjects

func (r *ProjectRepository) ListProjects() ([]*Project, error)

func (*ProjectRepository) ListWorkspaces

func (r *ProjectRepository) ListWorkspaces() ([]*WorkspaceSummary, error)

func (*ProjectRepository) SetProjectWorkspace

func (r *ProjectRepository) SetProjectWorkspace(projectID, workspaceID string) error

type ProjectStore

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

ProjectStore holds project metadata in memory, loaded from project.yaml files.

func NewProjectStore

func NewProjectStore(resolver KitResolver) *ProjectStore

NewProjectStore creates a new store. If resolver is non-nil, kit references in project.yaml files will be resolved and merged at load time.

func (*ProjectStore) Get

func (s *ProjectStore) Get(id string) (*ProjectMeta, bool)

Get returns the cached meta for a project.

func (*ProjectStore) Load

func (s *ProjectStore) Load(workDir string) (*ProjectMeta, error)

Load reads project.yaml from the work_dir and stores the meta in memory.

func (*ProjectStore) LoadAll

func (s *ProjectStore) LoadAll(projects []*Project) []error

LoadAll reads project.yaml for each registered project.

func (*ProjectStore) Remove

func (s *ProjectStore) Remove(id string)

Remove deletes a project's meta from the store.

func (*ProjectStore) Set

func (s *ProjectStore) Set(id string, meta *ProjectMeta)

Set stores meta directly.

type RawPayload

type RawPayload json.RawMessage

func (RawPayload) MarshalJSON

func (p RawPayload) MarshalJSON() ([]byte, error)

func (RawPayload) RawMessage

func (p RawPayload) RawMessage() json.RawMessage

func (*RawPayload) UnmarshalJSON

func (p *RawPayload) UnmarshalJSON(data []byte) error

UnmarshalJSON / MarshalJSON は json.RawMessage と同じ振る舞いを named type に 改めて再実装する。 named type はメソッドを継承しないため、 これが無いと encoding/json は underlying []byte 扱いで base64 文字列を要求してしまい、 JSON object/array 形式の default_payload を弾いてしまう。

func (*RawPayload) UnmarshalYAML

func (p *RawPayload) UnmarshalYAML(node *yaml.Node) error

type ReplayResult

type ReplayResult struct {
	Result       HandlerResult
	FinalPayload json.RawMessage
	NewStatus    TaskStatus
	FiredEvents  []FiredEvent
}

ReplayResult is the result of a single-hook replay operation.

type Role

type Role string
const (
	RoleHook Role = "hook"
)

type RoutedInstruction

type RoutedInstruction struct {
	Role    string `json:"role"`
	Agent   string `json:"agent"`
	Name    string `json:"name,omitempty"`
	Message string `json:"message"`
	Model   string `json:"model,omitempty"`
}

func FilterInstructions

func FilterInstructions(instructions Instructions, agent string) []RoutedInstruction

FilterInstructions returns the active routed instruction for the given agent. Only the most recent entry in the history is considered (older entries are kept for audit but do not drive dispatch). Returns nil when agent is empty or the active entry does not address it. Callers gate on status==executing before routing (see selectInstruction).

type Rule

type Rule struct {
	Action          string // manual transition trigger (mutually exclusive with Condition)
	FromStatus      string // "*" matches any
	ToStatus        string
	Condition       TransitionCondition                   // auto transition trigger (mutually exclusive with Action)
	Manual          bool                                  // true if the action is user-initiated (shown in available_actions)
	ActionPayloadFn func(json.RawMessage) json.RawMessage // optional; generates action.Payload when the rule fires
}

type StateMachine

type StateMachine struct {
	Name  string
	Rules []Rule
}

func DefaultMachine

func DefaultMachine() *StateMachine

DefaultMachine returns the unified state machine used by all tasks.

func NewMachine

func NewMachine() *StateMachine

NewMachine returns the unified state machine.

Manual transitions:

start  : pending → executing
done   : executing → done     (UI button; agent path goes through done_request + auto)
done   : awaiting → done      (parent confirms child's done_request)
fail   : executing → aborted  (UI button; agent path goes through fail_request + auto)
reopen : done → executing
reopen : aborted → executing  (recover from failure via fix)
ask    : executing → awaiting
answer : awaiting → executing
abort  : * → aborted

Event-driven transitions:

job_failed : * → aborted

Non-transitioning records (created directly by NotifyTask, bypassing ApplyAction):

progress      : * → *   (FYI timeline note)
done_request  : * → *   (agent's `notify --done` intent; consumed by DeriveLifecycle)
fail_request  : * → *   (agent's `notify --fail` intent; consumed by DeriveLifecycle)

Auto transitions (condition-based, evaluated after dispatch). Order matters — first match wins:

executing → aborted when lifecycle.executed && lifecycle.fail
executing → done    when lifecycle.executed && lifecycle.done
executing → done    when lifecycle.executed                     (legacy bare; non-agent hooks)

`lifecycle.{executed,done,fail}` are transient traits injected by the coordinator; they are never persisted to the payload. The state machine treats them as input signals derived from the action history (done_request / fail_request) plus the just-finished hook outcome.

The split between `done_request` (intent recorded immediately) and the auto-advance (state transition after `lifecycle.executed` confirms the runtime exited cleanly) preserves the bash EXIT trap → `boid job done` path. Without this split NotifyTask had to SIGTERM the runtime to apply the state transition synchronously, which raced against the SIGUSR1 graceful-stop path and left jobs marked failed.

Hook failures surface as job_failed via the dispatcher path, which routes the task to aborted.

func (*StateMachine) Advance

func (sm *StateMachine) Advance(task *Task) (*Task, bool)

Advance evaluates condition-based rules for the task's current status and payload. Returns the transitioned task and true if a condition was met, or (nil, false) otherwise. Use AdvanceFull when the action payload is also needed.

func (*StateMachine) AdvanceFull

func (sm *StateMachine) AdvanceFull(task *Task) *AdvanceOutcome

AdvanceFull evaluates condition-based rules for the task's current status and payload. Returns an AdvanceOutcome (including optional action payload) if a condition was met, or nil otherwise.

func (*StateMachine) Apply

func (sm *StateMachine) Apply(task *Task, action *Action) (*Task, error)

Apply finds an action-based rule matching the action type and current status. Condition-based rules are ignored by Apply. When a matching rule has an empty ToStatus the task status is left unchanged (non-transitioning action, e.g. "progress").

func (*StateMachine) AvailableActions

func (sm *StateMachine) AvailableActions(status TaskStatus) []string

AvailableActions returns the list of manual actions that can be applied to a task in the given status. Condition-based (automatic) rules and non-manual rules are excluded. Terminal statuses (done, aborted) return an empty list.

type Task

type Task struct {
	ID           string          `json:"id"`
	ProjectID    string          `json:"project_id"`
	RemoteID     string          `json:"remote_id,omitempty"`
	Title        string          `json:"title"`
	Description  string          `json:"description,omitempty"`
	Status       TaskStatus      `json:"status"`
	Behavior     string          `json:"behavior"`
	Traits       []string        `json:"traits,omitempty"`
	Readonly     bool            `json:"readonly,omitempty"`
	Worktree     bool            `json:"worktree,omitempty"`
	BranchPrefix string          `json:"branch_prefix,omitempty"`
	BaseBranch   string          `json:"base_branch,omitempty"`
	Payload      json.RawMessage `json:"payload"`
	Instructions Instructions    `json:"instructions,omitempty"`
	AutoStart    bool            `json:"auto_start,omitempty"`
	Ref          string          `json:"ref,omitempty"`
	ParentID     string          `json:"parent_id,omitempty"`
	CreatedAt    time.Time       `json:"created_at"`
	UpdatedAt    time.Time       `json:"updated_at"`
	// 以下はDBに保存しない派生フィールド(list/get クエリ時にサブクエリで集計)
	TotalChildCount   int `json:"total_child_count,omitempty"`
	DoneChildCount    int `json:"done_child_count,omitempty"`
	AbortedChildCount int `json:"aborted_child_count,omitempty"`
	OpenChildCount    int `json:"open_child_count,omitempty"`
	// Blocked は表示用フィールド(DBには保存しない)
	Blocked bool `json:"blocked,omitempty"`
}

func FindTaskByRef

func FindTaskByRef(dbtx db.DBTX, ref, parentID string) (*Task, error)

FindTaskByRef returns the task matching the given ref within the given parent scope, or nil if no matching task is found. If ref is a UUID, the task is looked up by id directly (backward compatibility).

func FindTaskByRemote

func FindTaskByRemote(dbtx db.DBTX, remoteID string) (*Task, error)

FindTaskByRemote returns the most recently created task (by created_at DESC, id DESC) matching the given remote_id, or nil if no matching task is found.

func GetTask

func GetTask(dbtx db.DBTX, id string) (*Task, error)

func ListChildren added in v0.0.6

func ListChildren(dbtx db.DBTX, parentID string) ([]*Task, error)

ListChildren returns all direct children of the given parent task, ordered by created_at ASC (oldest first). Returns an empty slice if the task has no children — never nil. parentID must be non-empty; passing "" returns an empty result (root tasks have no parent record, so they can't be queried as children either).

func ListTasks

func ListTasks(dbtx db.DBTX, filter TaskFilter) ([]*Task, error)

type TaskBehavior

type TaskBehavior struct {
	// Readonly controls whether the sandbox working directory is mounted read-only
	// for tasks using this behavior. When nil (unset), the daemon applies a
	// fail-safe default: readonly=true for all behaviors except the canonical
	// "executor" (which retains readonly=false during the compat period).
	// Set explicitly to override: readonly: false in project.yaml.
	Readonly           *bool        `yaml:"readonly,omitempty" json:"readonly,omitempty"`
	Traits             []string     `yaml:"traits" json:"traits"`
	DefaultInstruction *Instruction `yaml:"default_instruction,omitempty" json:"default_instruction,omitempty"`
	Kits               []KitRef     `yaml:"kits,omitempty" json:"kits,omitempty"`

	// Resolved fields populated by ReadProjectMetaWithKits after merging kit data
	// and project-level overlays. These are not serialized to YAML.
	Hooks              []Hook            `yaml:"-" json:"-"`
	Env                map[string]string `yaml:"-" json:"-"`
	HostCommands       HostCommands      `yaml:"-" json:"-"`
	AdditionalBindings []BindMount       `yaml:"-" json:"-"`
	// KitRoots holds the deduplicated list of kit root directories to bind-mount
	// in the sandbox at their original host paths. Populated by MergeKitMetaIntoBehavior.
	KitRoots []string `yaml:"-" json:"-"`
}

func LookupBehaviorWithAlias

func LookupBehaviorWithAlias(meta *ProjectMeta, name string) (TaskBehavior, string, bool)

LookupBehaviorWithAlias finds a TaskBehavior in meta.TaskBehaviors by name, being tolerant of the plan / dev → supervisor / executor rename. Lookup is tried in this order:

  1. exact match against the requested name
  2. if the request is a legacy alias, try the canonical name
  3. if the request is a canonical name, try the legacy alias (handles unnormalized in-memory ProjectMeta values that may exist in tests or transitional code paths)

When (2) or (3) hits, a deprecation warning is logged. The returned key is the map key that actually matched; callers may use it for further logging or store the canonical form on the task.

type TaskFilter

type TaskFilter struct {
	Status      string
	ProjectID   string
	Behavior    string
	WorkspaceID string
	Title       string
	ParentID    *string
}

type TaskGCStore

type TaskGCStore struct {

	// RuntimeReaper, when set, is called with each runtime directory path
	// before os.RemoveAll removes it. Use this to Reap docker resources that
	// may still be alive in the upstream daemon (safety net for jobs whose
	// cleanupSandboxAfterWait did not complete, e.g. daemon restart).
	RuntimeReaper func(runtimeDir string) error
	// contains filtered or unexported fields
}

TaskGCStore handles GC of tasks and their related data.

func NewTaskGCStore

func NewTaskGCStore(conn *sql.DB) *TaskGCStore

func NewTaskGCStoreWithWorktree

func NewTaskGCStoreWithWorktree(conn *sql.DB, resolveProjectDir func(projectID string) (string, error), gitBin string, runtimesDir string) *TaskGCStore

NewTaskGCStoreWithWorktree creates a TaskGCStore that also cleans up worktree directories on disk before deleting DB records. resolveProjectDir returns the project's work directory given its ID. gitBin is the path to the git binary; empty string defaults to "git". runtimesDir is the path to the runtimes root directory; empty string disables runtime cleanup.

func (*TaskGCStore) GC

func (s *TaskGCStore) GC(olderThan time.Duration, dryRun bool) (*GCResult, error)

func (*TaskGCStore) WithAttachmentsRoot added in v0.0.7

func (s *TaskGCStore) WithAttachmentsRoot(dir string) *TaskGCStore

WithAttachmentsRoot enables disk-level cleanup of the per-task attachments directory tree rooted at `<dir>/tasks/<id>/attachments`. dir is the data-home (matches dataHomeFor in wire.go). Empty disables the cleanup.

func (*TaskGCStore) WithRuntimeReaper added in v0.0.2

func (s *TaskGCStore) WithRuntimeReaper(fn func(runtimeDir string) error) *TaskGCStore

WithRuntimeReaper sets a callback that is invoked with each runtime directory path before it is deleted. This allows the caller to Reap docker resources created by sandbox jobs (safety net when cleanupSandboxAfterWait didn't run, e.g. after a daemon restart).

func (*TaskGCStore) WithSandboxTmpDir

func (s *TaskGCStore) WithSandboxTmpDir(dir string) *TaskGCStore

WithSandboxTmpDir enables safety-net cleanup of leaked /tmp/boid-* sandbox artifacts during GC. Pass the directory to scan (typically "/tmp"); empty string disables this cleanup.

type TaskLookup

type TaskLookup interface {
	GetTask(id string) (*Task, error)
}

type TaskRepository

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

func NewTaskRepository

func NewTaskRepository(db db.DBTX) *TaskRepository

func (*TaskRepository) CreateAction

func (r *TaskRepository) CreateAction(action *Action) error

func (*TaskRepository) CreateTask

func (r *TaskRepository) CreateTask(task *Task) error

func (*TaskRepository) DeleteTask

func (r *TaskRepository) DeleteTask(id string) error

func (*TaskRepository) FindDependentTasks

func (r *TaskRepository) FindDependentTasks(_ string) ([]*Task, error)

func (*TaskRepository) FindTaskByRef

func (r *TaskRepository) FindTaskByRef(ref, parentID string) (*Task, error)

func (*TaskRepository) FindTaskByRemote

func (r *TaskRepository) FindTaskByRemote(remoteID string) (*Task, error)

func (*TaskRepository) GetTask

func (r *TaskRepository) GetTask(id string) (*Task, error)

func (*TaskRepository) ListActionsByTask

func (r *TaskRepository) ListActionsByTask(taskID string) ([]*Action, error)

func (*TaskRepository) ListChildren added in v0.0.6

func (r *TaskRepository) ListChildren(parentID string) ([]*Task, error)

func (*TaskRepository) ListTasks

func (r *TaskRepository) ListTasks(filter TaskFilter) ([]*Task, error)

func (*TaskRepository) UpdateTask

func (r *TaskRepository) UpdateTask(task *Task) error

type TaskSnapshot

type TaskSnapshot struct {
	ID          string
	Title       string
	Status      string
	Behavior    string
	Description string
}

TaskSnapshot is the business metadata that materializes at $HOME/.boid/context/task.yaml. Fields mirror the subset historically produced by planner's buildTaskYAML helper.

type TaskStatus

type TaskStatus string
const (
	TaskStatusPending   TaskStatus = "pending"
	TaskStatusExecuting TaskStatus = "executing"
	TaskStatusAwaiting  TaskStatus = "awaiting"
	TaskStatusDone      TaskStatus = "done"
	TaskStatusAborted   TaskStatus = "aborted"
)

type TraitType

type TraitType string
const (
	TraitArtifact     TraitType = "artifact"
	TraitVerification TraitType = "verification"
	TraitAwaiting     TraitType = "awaiting"
)

func ActiveTraitTypes

func ActiveTraitTypes(raw json.RawMessage) ([]TraitType, error)

func (TraitType) Base

func (t TraitType) Base() TraitType

Base returns the trait name without the optional "?" suffix.

func (TraitType) IsOptional

func (t TraitType) IsOptional() bool

IsOptional reports whether the trait is declared with a trailing "?".

type TransitionCondition

type TransitionCondition func(payload json.RawMessage) bool

TransitionCondition evaluates whether a condition-based transition should fire.

type Visibility

type Visibility struct {
	// ProjectDir is the host path to the project working directory.
	// Empty means the project filesystem is not visible.
	ProjectDir string

	// UseWorktree asks dispatcher to replace ProjectDir with a per-task git
	// worktree obtained from its WorktreeManager.
	UseWorktree bool

	// AdditionalBindings lists extra host bind-mounts (e.g. kit-provided CLIs
	// like the claude binary) that must be visible inside the sandbox.
	AdditionalBindings []BindMount

	// Writable permits writes to ProjectDir / the resolved worktree. When
	// ProjectDir is empty, this field has no effect.
	Writable bool

	// KitRoots lists the kit root directories to bind-mount at their original
	// host paths inside the sandbox. This lets scripts source sibling helpers
	// via relative paths (e.g. ${SCRIPT_DIR}/../scripts/lib.sh).
	KitRoots []string

	// ForkPoint is ProjectMeta.ForkPoint passed through to the dispatcher.
	// Used as the start point when a worktree's base_branch does not exist
	// (ClassifyBaseBranch case 3). Empty means dispatcher falls back to
	// refs/remotes/origin/HEAD.
	ForkPoint string

	// DockerEnabled, when true, indicates capabilities.docker was declared in
	// project.yaml. Dispatcher uses this to start a per-sandbox docker proxy.
	DockerEnabled bool
}

Visibility captures which host paths the sandbox sees and whether they are writable. orchestrator sets this once per JobSpec; dispatcher turns it into mount entries with no further role-aware logic.

type WorkspaceSummary

type WorkspaceSummary struct {
	ID           string `json:"id"`
	ProjectCount int    `json:"project_count"`
}

func ListWorkspaces

func ListWorkspaces(dbtx db.DBTX) ([]*WorkspaceSummary, error)

ListWorkspaces returns all configured workspaces with project counts.

type WorktreeLocker

type WorktreeLocker interface {
	Acquire(ctx context.Context, key string) (release func(), err error)
}

WorktreeLocker manages exclusive write access to a shared worktree.

Directories

Path Synopsis
Package refname generates random "adjective_noun" style names for task refs.
Package refname generates random "adjective_noun" style names for task refs.

Jump to

Keyboard shortcuts

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