Documentation
¶
Overview ¶
Package schedgate is the shared "should this scheduled bot fire now?" gate used by all three scheduled-launch paths: pkg/cli/schedule (host crontab), pkg/trigger.Scheduler (in-process spine), and pkg/cloudsched (multi-replica cloud ticker). It provides three composable pieces — an overlap decision over live runs, a guard-command executor, and a tick-record shape — with no I/O of its own beyond running the guard subprocess. Each surface keeps its own persistence (JSONL locally, pkg/audit in cloud mode).
Index ¶
- Constants
- func DefaultLocalAuditPath() (string, error)
- func LiveAndStaleRunsForSchedule(ctx context.Context, s ScheduleRunLister, scheduleID string, ...) (live, stale []string)
- func LiveRunsForSchedule(ctx context.Context, s ScheduleRunLister, scheduleID string, ...) []string
- func LocalAuditPathFor(manifestPath string) string
- func TruncTail(s string, max int) string
- func Validate(p Policy) error
- type Decision
- type GateInput
- type GateOutcome
- type GuardKind
- type GuardResult
- type GuardSpec
- type Policy
- type ScheduleRunLister
- type Surface
- type TickDecision
- type TickRecord
Constants ¶
const ( OverlapSkip = "skip" OverlapAllow = "allow" OverlapKeepalive = "keepalive" )
Overlap policy values. An empty Overlap normalizes to OverlapSkip: firing while a previous run of the same schedule is still live is a latent-bug default (a nightly that overruns its window piles up concurrent runs on the same repo), so skip is the safe baseline and allow is the explicit opt-in.
OverlapKeepalive is the "always-on agent" policy: at-most-one-live (like skip), but a live run that has gone silent past StaleAfter is treated as dead — dropped from the live set so the tick relaunches a fresh run, and surfaced for reaping. It is what keeps a bot continuously alive across short, individually-budgeted runs rather than one immortal run.
const ( DefaultGuardTimeout = 30 * time.Second DefaultGuardVar = "guard_output" // DefaultStaleAfter is the keepalive silence cutoff: a running run // whose last progress is older than this is treated as dead. Chosen // generously so a long-but-alive run is not falsely reaped; override // per schedule (ideally >= the bot's max_duration). DefaultStaleAfter = 5 * time.Minute )
Guard defaults.
const TailCap = 16 * 1024
TailCap bounds the stdout/stderr tails stored on audit records. The tail (not the head) is preserved: shell errors print last.
const TickSchemaVersion = 1
TickSchemaVersion is the current TickRecord schema.
Variables ¶
This section is empty.
Functions ¶
func DefaultLocalAuditPath ¶
DefaultLocalAuditPath resolves the local tick-audit file the way the schedule CLI resolves its manifest (ITERION_SCHEDULES_FILE override, else ~/.iterion/schedules.yaml), so the in-process trigger scheduler writes to the SAME file `iterion schedule audit` reads.
func LiveAndStaleRunsForSchedule ¶ added in v1.0.0
func LiveAndStaleRunsForSchedule(ctx context.Context, s ScheduleRunLister, scheduleID string, staleAfter time.Duration, now time.Time, logger *iterlog.Logger) (live, stale []string)
LiveAndStaleRunsForSchedule is the keepalive-aware liveness query. It returns two disjoint, order-preserving lists of non-terminal runs stamped with this schedule's provenance:
- live: runs that count against the overlap policy;
- stale: running runs whose last progress (UpdatedAt) is older than staleAfter — treated as dead so a keepalive tick relaunches, and returned so the caller can reap them.
When staleAfter <= 0 (the non-keepalive path) no run is ever considered stale, so this reduces exactly to the old LiveRunsForSchedule behavior. Staleness is gated on RunStatusRunning only: a paused run is legitimately idle and must never be reaped.
func LiveRunsForSchedule ¶
func LiveRunsForSchedule(ctx context.Context, s ScheduleRunLister, scheduleID string, logger *iterlog.Logger) []string
LiveRunsForSchedule returns the IDs of runs stamped with this schedule's provenance whose status is non-terminal, preserving the store's created_at-ascending order (so index 0 is the oldest — the deterministic "blocking run" for audit).
A run that fails to load is logged and skipped rather than blocking the tick: this is a gate, not a data-integrity boundary.
func LocalAuditPathFor ¶
LocalAuditPathFor places the tick-audit JSONL next to the schedule manifest's cron logs: <manifest-dir>/logs/tick-audit.jsonl. Shared by the host-cron surface (which knows its manifest path exactly) and DefaultLocalAuditPath below.
Types ¶
type Decision ¶
type Decision int
Decision is the outcome of EvaluateOverlap.
func EvaluateOverlap ¶
EvaluateOverlap decides whether a tick may fire given the schedule's currently-live run IDs. Pure. On skip, the returned blocking ID is the first live run (the store lists them created_at-ascending, so "first" is the oldest — deterministic across replicas) for the audit reason.
type GateInput ¶
type GateInput struct {
Policy Policy
// Lister powers the overlap check; nil skips it (a surface with no
// store access degrades to guard-only, never to a hard error).
Lister ScheduleRunLister
// ScheduleID is the provenance key runs were stamped with
// (RunSource.ScheduleID) — what LiveRunsForSchedule queries.
ScheduleID string
// Record is the surface-prefilled tick record (surface, ids,
// tenant, cron, timestamp). Apply stamps the decision, reason and
// guard fields on the copy it returns.
Record TickRecord
// GuardDir/GuardEnv shape the guard subprocess (see GuardSpec).
GuardDir string
GuardEnv []string
Logger *iterlog.Logger
// Now overrides the clock for keepalive staleness detection (zero =
// time.Now()). Tests inject a fixed instant; production leaves it zero.
Now time.Time
}
GateInput bundles what a launch surface knows at tick time. The three scheduled-launch surfaces (host-cron, trigger spine, cloud ticker) all evaluate the SAME overlap→guard sequence; only the audit sink, the human reporting, and the guard's cwd/env differ — so those stay with the caller and everything else lives in Apply.
type GateOutcome ¶
type GateOutcome struct {
// Proceed: launch the run. False: the slot passes, and Record
// carries the audited reason (skipped_overlap / guard_blocked /
// guard_error).
Proceed bool
// GuardRan is true when a guard command executed and passed —
// callers then inject GuardStdout as vars[Policy.GuardVar], even
// when the stdout is empty (the var's presence is the contract).
GuardRan bool
GuardStdout string
// Record is the input record stamped with the decision. On
// Proceed it is left undecided — "fired" is only true after the
// launch attempt, which the caller owns.
Record TickRecord
// ReapRunIDs lists keepalive runs found stale (silent past
// StaleAfter) at this tick. The caller cancels them via its store so
// the zombies free resources; schedgate stays I/O-free. Non-empty
// only on the keepalive path, and only alongside Proceed (a stale
// run no longer blocks, so the tick fires a fresh one).
ReapRunIDs []string
}
GateOutcome is the gate's verdict for one consumed tick slot.
type GuardKind ¶
type GuardKind int
GuardKind classifies a guard execution outcome.
const ( // GuardOK: exit 0 — the run fires and Stdout becomes vars[guard_var]. GuardOK GuardKind = iota // GuardBlocked: exit non-zero — the guard deliberately said "nothing // to do"; the tick is skipped. GuardBlocked // GuardError: the guard itself broke — spawn failure or timeout. // Distinct from GuardBlocked so an operator can tell "guard said no" // from "guard is broken". GuardError )
type GuardResult ¶
type GuardResult struct {
Kind GuardKind
// ExitCode is the guard's exit status for GuardBlocked; -1 for
// GuardError (no meaningful status).
ExitCode int
// Stdout is the full stdout on GuardOK (it may be structured input,
// e.g. a JSON issue list — never truncated); the TruncTail'd tail
// otherwise.
Stdout string
// StderrTail is always the TruncTail'd stderr.
StderrTail string
// Err is nil on GuardOK.
Err error
// Duration is the wall-clock guard runtime.
Duration time.Duration
}
GuardResult is what the audit and the launch layer consume.
func RunGuard ¶
func RunGuard(parentCtx context.Context, spec GuardSpec) GuardResult
RunGuard executes spec.Command with `sh -lc` under its OWN timeout-bounded context derived from parentCtx. It never installs a deadline on parentCtx itself: the run has not started yet, and a guard timeout must not cascade into the caller's tick loop.
cmd.WaitDelay bounds the orphan-pipe wait when the timeout kills `sh` but a grandchild keeps the inherited pipes open (same discipline as dispatcher hooks).
type GuardSpec ¶
type GuardSpec struct {
// Command is the sh -lc snippet.
Command string
// Dir is the working directory (the schedule's workdir, so `gh` /
// `git` run in repo context).
Dir string
// Env entries are appended to the inherited os.Environ.
Env []string
// Timeout bounds the subprocess; <= 0 means DefaultGuardTimeout.
Timeout time.Duration
}
GuardSpec is the resolved input to RunGuard.
type Policy ¶
type Policy struct {
// Overlap is "skip" (default: don't fire while a previous run of
// this schedule is live) or "allow".
Overlap string `yaml:"overlap,omitempty" json:"overlap,omitempty" bson:"overlap,omitempty"`
// MaxConcurrent caps live runs when Overlap is "allow", inclusive
// of the run about to fire (2 = fire while fewer than 2 are live).
// 0 with "allow" means unlimited. Invalid with "skip".
MaxConcurrent int `yaml:"max_concurrent,omitempty" json:"max_concurrent,omitempty" bson:"max_concurrent,omitempty"`
// Guard is an optional `sh -lc` snippet run before any launch:
// exit 0 fires the run and the guard's stdout becomes the run's
// vars[GuardVar]; non-zero skips the tick.
Guard string `yaml:"guard,omitempty" json:"guard,omitempty" bson:"guard,omitempty"`
// GuardTimeout bounds the guard subprocess (Go duration string,
// default 30s).
GuardTimeout string `yaml:"guard_timeout,omitempty" json:"guard_timeout,omitempty" bson:"guard_timeout,omitempty"`
// GuardVar names the workflow var receiving the guard's stdout
// (default "guard_output").
GuardVar string `yaml:"guard_var,omitempty" json:"guard_var,omitempty" bson:"guard_var,omitempty"`
// StaleAfter is the keepalive silence cutoff (Go duration string): a
// running run whose last progress is older than this counts as dead,
// so a fresh run relaunches and the zombie is reaped. Only meaningful
// with Overlap=keepalive; empty normalizes to DefaultStaleAfter.
StaleAfter string `yaml:"stale_after,omitempty" json:"stale_after,omitempty" bson:"stale_after,omitempty"`
}
Policy is the concurrency + guard contract shared by all three surfaces. Zero value is legal (skip, no guard); Normalize promotes it to explicit defaults. All fields are additive on their host schemas — old manifests / rows load unchanged.
func Normalize ¶
Normalize returns p with defaults applied. Idempotent; never returns a Policy with empty Overlap, GuardTimeout or GuardVar.
func (Policy) GuardTimeoutDuration ¶
GuardTimeoutDuration resolves the policy's guard timeout, falling back to the default on empty or unparseable values (Validate rejects the latter upstream; the fallback keeps runtime behavior total).
func (Policy) StaleAfterDuration ¶ added in v1.0.0
StaleAfterDuration resolves the keepalive silence cutoff, falling back to DefaultStaleAfter on empty or unparseable values (Validate rejects the latter upstream; the fallback keeps runtime behavior total).
type ScheduleRunLister ¶
type ScheduleRunLister interface {
ListRunsBySchedule(ctx context.Context, scheduleID string) ([]string, error)
LoadRun(ctx context.Context, id string) (*store.Run, error)
}
ScheduleRunLister is the narrow slice of store.RunStore the overlap gate needs. Both the filesystem and Mongo stores satisfy it (via ListRunsBySchedule); kept as a local interface so tests inject fakes and this package stays store-agnostic.
type Surface ¶
type Surface string
Surface names the scheduled-launch path that made a tick decision, so mixed audit streams can be filtered per-source.
type TickDecision ¶
type TickDecision string
TickDecision is the audit-level outcome of one tick. Exactly one tag lands on each record.
const ( // TickFired: overlap + guard passed and a launch was attempted. TickFired TickDecision = "fired" // TickSkippedOverlap: a live run of the same schedule held the slot. TickSkippedOverlap TickDecision = "skipped_overlap" // TickGuardBlocked: the guard exited non-zero ("nothing to do"). TickGuardBlocked TickDecision = "guard_blocked" // TickGuardError: the guard failed to execute (spawn error/timeout). TickGuardError TickDecision = "guard_error" )
type TickRecord ¶
type TickRecord struct {
// Schema is the record format version (currently 1). Readers must
// ignore unknown fields and reject unknown higher schemas loudly.
Schema int `json:"schema"`
// Surface is the launch path that made the decision.
Surface Surface `json:"surface"`
// ScheduleID is the stable schedule identity: ScheduleEntry.Name
// (host-cron), Subscription.ID (trigger), ScheduledBot.ID (cloud).
ScheduleID string `json:"schedule_id"`
// ScheduleName is the human label when it differs from the ID.
ScheduleName string `json:"schedule_name,omitempty"`
BotID string `json:"bot_id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
Cron string `json:"cron,omitempty"`
At time.Time `json:"at"`
Decision TickDecision `json:"decision"`
// Reason is a human sentence, e.g. "blocked by live run r_x".
Reason string `json:"reason,omitempty"`
// RunID is the launched run on TickFired.
RunID string `json:"run_id,omitempty"`
// BlockingRunID names the (oldest) live run on TickSkippedOverlap so
// the operator can inspect or cancel it.
BlockingRunID string `json:"blocking_run_id,omitempty"`
// GuardExit is the guard's exit code — pointer so 0 is meaningful.
GuardExit *int `json:"guard_exit,omitempty"`
GuardDuration time.Duration `json:"guard_duration_ns,omitempty"`
StdoutTail string `json:"stdout_tail,omitempty"`
StderrTail string `json:"stderr_tail,omitempty"`
Error string `json:"error,omitempty"`
}
TickRecord is the shared audit row written by every surface — as a JSONL line locally (~/.iterion/logs/tick-audit.jsonl), as audit.Event Meta in cloud mode. It answers "why didn't my scheduled bot fire last night?" deterministically. Field names are stable: operators grep the JSONL.
func NewTickRecord ¶
func NewTickRecord(surface Surface, scheduleID string, at time.Time, decision TickDecision) TickRecord
NewTickRecord builds a record with the invariant fields stamped.
func (*TickRecord) ApplyGuard ¶
func (t *TickRecord) ApplyGuard(res GuardResult)
ApplyGuard folds a guard result into the record (exit code, tails, duration, error text). The decision itself stays the caller's choice so a GuardOK record can still be TickFired.
func (TickRecord) ToAuditMeta ¶
func (t TickRecord) ToAuditMeta() map[string]any
ToAuditMeta projects the record onto the map shape pkg/audit stores in Event.Meta. Kept here — not in each surface — so the cloud rows and the local JSONL stay field-compatible.