Documentation
¶
Overview ¶
Package lifecycle implements workspace lifecycle automation: the Reaper loop finds RUNNING agent runs that have been idle past their policy's AutoStopAfterSec threshold and stops them, emitting a "run.autostop" audit event for each.
Idleness definition (v0) — KNOWN LIMITATION ¶
"Idleness" here is NOT activity-based: it is wall-clock age of agent_runs.updated_at. The clock resets only when something writes that row through the store — state transitions, sandbox_ref updates, or an explicit store.TouchRun keepalive (which the interactive-attach handler calls). It does NOT reset on genuine in-sandbox agent activity (CPU, proxied egress, file writes) that never touches the store row. Consequently a run that is busy but whose store row is otherwise unchanged will be stopped once updated_at ages past its policy threshold.
This is a deliberate v0 simplification. The seam to do better already exists: store.TouchRun bumps updated_at without other side effects, so once the wardyn-proxy bumps updated_at on every proxied request (or the runner reports liveness), genuine activity will keep the workspace alive automatically — no change to this package is required. Until then, operators who need an unbounded session should use the never-reap escape hatch (policy AutoStopAfterSec < 0). This residual risk is documented here and should be mirrored in threatmodel/ if appropriate.
AutoStopAfterSec semantics (policy auto_stop_after_sec) ¶
> 0 idle timeout in seconds (stop after this much wall-clock idleness)
0 DISABLED — the run is never reaped (matches docs/policies; this is the
default for a run with no auto-stop configured, since the store COALESCEs
an absent policy field to 0)
< 0 never reaped (explicit interactive escape hatch; equivalent to 0 for the
reaper, but kept distinct so an operator can express intent loudly)
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// Interval is how often the reaper scans. Default: 1 minute.
Interval time.Duration
// Now overrides the wall clock. Nil means use real time.
// (overridable in tests, mirrors embedded.Provider's now func idiom)
Now func() time.Time
}
Config holds optional overrides for Reaper behaviour. Zero value is valid: defaults are applied in New.
type Reaper ¶
type Reaper struct {
// contains filtered or unexported fields
}
Reaper is the idle-workspace garbage collector. It runs a periodic loop that finds RUNNING workspaces idle past their policy threshold and stops them.
Reaper.Run is designed to be called in a dedicated goroutine and blocks until ctx is cancelled. A stopper error is logged and skipped so that one broken sandbox never prevents the rest from being reaped on the same tick.
func New ¶
New constructs a Reaper. store, stopper, and recorder are required and must be non-nil. cfg may be zero-valued.
type Recorder ¶
type Recorder interface {
Record(ctx context.Context, ev types.AuditEvent) error
}
Recorder is the minimal audit interface the Reaper needs. It matches audit.Recorder exactly so the integrator can pass a store.Recorder directly.
type RunSummary ¶
type RunSummary struct {
ID uuid.UUID
UpdatedAt time.Time
// PolicyAutoStopAfterSec is the value from the run's attached policy spec.
// Semantics (see package doc): 0 means DISABLED (never reap), a NEGATIVE value
// also means "never reap", and a POSITIVE value is the idle timeout in seconds.
// The Store must JOIN to the policy table and surface this field; a zero value
// is intentional (a run with no auto-stop configured is never reaped, not a
// missing join).
PolicyAutoStopAfterSec int
}
RunSummary is the minimal projection a Store must return for idle detection. It carries everything the Reaper needs without leaking the full run row.
type StopOutcome ¶
type StopOutcome struct {
// Applied reports whether the stop actually transitioned the run from RUNNING
// to STOPPED. It is false when a concurrent kill/complete had already moved the
// run terminal, OR when the idleness guard no-op'd because the run's updated_at
// advanced past the reaper's snapshot (an active `wardyn attach` touched it
// after the scan — finding N3). Either way the reaper must NOT emit a spurious
// run.autostop (finding #1).
Applied bool
// Errors, when non-empty, carries the teardown/revocation failures that
// occurred AFTER the stop transition won (Applied=true) but which left the run
// not fully contained: "identity_error" / "broker_error" (the run token or
// minted broker creds may still be live — finding N1), and "teardown_error"
// (the sandbox may still be routable). The reaper emits these as a distinct
// run.revoke/failure audit event so the live-credential/live-sandbox window is
// visible, mirroring handleKillRun/revokeRunCascade. Nil on a clean stop.
Errors map[string]string
}
StopOutcome is what StopRun reports back to the Reaper beyond the raw error.
type Stopper ¶
type Stopper interface {
// StopRun gracefully stops the run identified by runID. Implementations must
// be idempotent: stopping an already-stopped run returns ({Applied:false}, nil).
//
// notAfter is the run's updated_at from the reaper's tick snapshot: the stop
// transition must be conditional on updated_at not having advanced past it, so
// a run an active attach touched after the snapshot is NOT stopped (finding N3).
//
// The returned StopOutcome reports whether the transition applied and any
// post-transition teardown/revocation failures. A non-nil error means the stop
// failed outright; the outcome is meaningless and the reaper logs and skips.
StopRun(ctx context.Context, runID uuid.UUID, notAfter time.Time) (StopOutcome, error)
}
Stopper stops a single run. The implementation is expected to conditionally transition the run's state to STOPPED (RUNNING->STOPPED only, guarded on the snapshot's idleness) and then call runner.StopSandbox + the revoke cascade; all concerns are hidden behind this interface so the lifecycle package stays target-agnostic.
type Store ¶
type Store interface {
// ListRunningWithPolicy returns all runs currently in state RUNNING
// together with the auto_stop_after_sec value from their attached policy
// (0 when no policy is attached or the policy field is unset).
ListRunningWithPolicy(ctx context.Context) ([]RunSummary, error)
}
Store is the narrow persistence interface the Reaper requires.
The real adapter is trivial: store.ListRunningWithPolicy wraps the pgxpool and maps from (agent_runs JOIN run_policies). Tests supply a fake.
Method shapes deliberately mirror the existing store package naming conventions (List*, returning a slice) so the integrator's adapter is mechanical.