Time out pending workflows no agent takes (RIG-5475)
Errata: E1, E2, E3 (2026-10-17) — see §Errata
Decisions
Section titled “Decisions”Matt ruled on RIG-5485 (2026-10-16). D1–D5 record the ruling.
- D1 (DL-530) — A queued workflow times out when its task has been
runnable in
Info.Pendinglonger thanpending-timeoutand no live agent matches its labels. A pool at zero has no agent rows, so for scale-from-zero pools this is age-only: a pool outage longer than the timeout errors real work. Ahostname-pinned task never times out. - D2 (DL-531) —
pending-timeoutdefaults to6h, on by default;0disables. That is about twice the worst observed backlog wait, and a backlog on a pool with live agents never trips it. - D3 (DL-532) — A timed-out workflow and its pending steps end
error, with the reason in theirErrorfields. Nevercanceled: it posts success. - D4 (DL-533) — The sweep finalizes only the timed-out workflow and the
dependents that can never run. Running siblings and
runs_on: failuredependents continue. - D5 (DL-534) — An agent counts as live when
NoScheduleis false and itsLastContactis withinpending-timeout.ReportHealthstoresLastContactevery 10 s, and it survives a restart. Agents that ever reported do not count: a persisted-config or individual-token agent never removes its row (UnregisterAgentin server/rpc/rpc.go).
Problem / Intent
Section titled “Problem / Intent”Five pipelines in a consumer repo sat pending for 7 to 33 days. Their 12 tasks
carried type=linux, a label no agent advertises. Nothing removes such a task:
the queue only expires running leases (fifo.resubmitExpiredPipelines in
server/queue/fifo.go), and the orphan reaper skips queued workflows. The tasks
inflate pending_count and hold forge checks pending. Matt ruled on RIG-5470
that they must be cancelled automatically after a timeout. The cancel ends the
workflow error, because a never-started canceled pipeline posts success.
Approach
Section titled “Approach”Where it runs
Section titled “Where it runs”ReapOrphanedWorkflows (server/pipeline/reap.go) walks every pending and
running pipeline, loads its tree, and skips each workflow in the queued set
built from storage.TaskList() and Scheduler.Info:
if _, exists := queued[fmt.Sprint(workflow.ID)]; exists { continue}The new checks run in this pass. The reaper tail finishes the pipeline, posts
forge status and publishes the event. pending-timeout or
orphan-reap-interval set to 0 turns them off.
When a queued workflow times out
Section titled “When a queued workflow times out”All must hold:
pending-timeoutis above0, and the queue is not paused (Info.Paused).- Its task is in
Info.Pending.Info.WaitingOnDepsis excluded: the autoscaler counts demand only fromqueueInfo.Pending(calcAgentsin engine/autoscaler.go), so a dependent’s pool can sit at zero while its dependency runs.Info.Runningis leased. - The task has been runnable for longer than
pending-timeout. The clock is the latest ofpipeline.Created,pipeline.Reviewed(set on approval) andFinishedof each workflow intask.Dependencies, from the loaded tree. All are stored, so a restart neither resets nor extends it.task.Createdis not used: older rows hold0. A clock of0never times out. - Every dependency is terminal and lets it run (Dependents that can never run).
- No live agent matches the task labels (Matching against live agents).
A blocked pipeline has no task: Create returns before start while it is
StatusBlocked. Backlog and scale-from-zero tasks wait up to the timeout.
Finalizing a timed-out workflow
Section titled “Finalizing a timed-out workflow”reapWorkflowgets the reason. The workflow and each pending step enderror, with the reason inErrorandFinishedset, so the step log bar shows it. A started step (a resubmitted lease) endskilled, as today. Example reason:no agent took this workflow within 6h0m0s (pending-timeout); labels: [type=linux]. Labels are sortedkey=valuepairs, withoutwoodpecker-ci.org/labels, empty values,repoandorg-id.- Then
Scheduler.CancelWorkflows(ctx, []string{id}), one ID per call:persistentQueue.ErrorAtOncedeletes no row when one ID is missing. A failed store write leaves the workflow pending for the next pass. A failed cancel is logged (Stale task rows). - The tail finishes the pipeline when nothing is pending or running.
statusPriorityOrder(server/pipeline/status.go) rankserrorabove running and pending, so a pull request aggregate turns red at once. The tail’sif changed && !pipelineEverStarted { status = model.StatusCanceled }is not applied when a workflow in the tree iserror:convertStatus(server/forge/github/convert.go) postscanceledas success (case model.StatusCanceled: ... return statusSuccess). - One
log.Infoline per finalized workflow, withreason=pending-timeoutorreason=dependency-failedand the labels. The counterwoodpecker_pending_workflows_timed_out_totalcounts timeouts.
An agent that takes the task after the Info snapshot gets ErrCancel, and
checkWorkflowState makes RPC.Done reject its report.
Dependents that can never run
Section titled “Dependents that can never run”Each pass checks every pending workflow whose task is in Info.Pending or
Info.WaitingOnDeps, so a failed write is retried next pass. DepStatus is
the State of each workflow in task.Dependencies, from the tree. If one is
missing or Running(), the check waits. Otherwise the workflow is dead when
(&model.Task{RunOn: task.RunOn, DepStatus: deps}).ShouldRun() is false. All
dependencies count: a runs_on: failure dependent of one timed-out and one
successful workflow is dead.
A dead workflow ends skipped through Cancel’s helpers:
UpdateWorkflowToStatusSkipped, and UpdateStepToStatusSkipped(store, step, 0, model.StatusCanceled) for pending steps, then CancelWorkflows. The scan
repeats until it finalizes nothing. The fifo cannot do it: its skip waits
until a matching agent polls (markSkipped in impl.Poll), and the stored
DepStatus stays empty, so after a restart the dependent runs.
Stale task rows
Section titled “Stale task rows”persistentQueue.ErrorAtOnce drops the task from memory before it deletes the
row. If the delete fails, nothing revisits the row, and a restart reloads it.
So each pass checks every TaskList() row against the workflow state loaded at
the start of the pass, or storage.WorkflowLoad for a pipeline not walked. A
row whose workflow is terminal or missing goes. If its task is in
Info.Pending or Info.WaitingOnDeps, the pass calls what
persistentQueue.Poll calls: Scheduler.Done with the workflow state, or
Scheduler.Error when it is missing. Both delete the row. Otherwise it calls
storage.TaskDelete. Info.Running rows are left to their lease. Done
passes the real state, so dependents of a stale success row are not killed.
Matching against live agents
Section titled “Matching against live agents”The server stores every label an agent polls with except hostname. The agent
builds its poll labels in run (cmd/agent/core/agent.go):
labels[pipeline.LabelFilterHostname] = hostnamelabels[pipeline.LabelFilterPlatform] = engInfo.Platformlabels[pipeline.LabelFilterBackend] = backendEngine.Name()labels[pipeline.LabelFilterRepo] = "*" // allow all repos by default// ... and let it overwrite by custom onesmaps.Copy(labels, customLabels)RPC.RegisterAgent stores Platform, Backend and CustomLabels, and
RPC.Next copies Agent.GetServerLabels() over the poll labels.
Agent.PollLabels rebuilds that map with hostname="*" unless a custom label
sets it, so a hostname-pinned task never times out. scheduler.NewLabelFilter
wraps createFilterFunc, so the rules exist once. An agent counts when
NoSchedule is false, because RPC.Next returns before Poll for it, and its
LastContact is within pending-timeout (D5). The autoscaler deletes a
drained agent (removeDrainedAgents) or clears the flag (createAgents).
Alternatives considered
Section titled “Alternatives considered”- Sweep inside
fifo.process. No store, agent rows or pipeline times there, and the loop holds the fork’s ordering rules. - A separate ticker. A second scan and a second reaper tail.
- Reject at enqueue when no agent matches. Breaks scale from zero.
- Final state
canceled. Posts success. - Final state
killed. Reads as a user stop, and ranks below running. - WaitingOnDeps candidates with a creation clock. Errors dependents whose pool is at zero by design.
- Queue cancel before the store write. A failed write is then reaped as
agent-goneandcanceled: success. - Probe with only the timed-out dependency. Misses a dead
runs_on: failuredependent whose other dependency succeeded. - Check dependents only after a timeout in that pass. A failed write is never retried.
- Drop stale queued tasks with
CancelWorkflows. Kills dependents of a stalesuccessworkflow. - Age only,
72h(option A). Less code, but a task that can never run holds its check pending for 3 days. - Agent match with a short grace (option B). Every scale-from-zero task looks unschedulable until its agent boots.
Global Constraints
Section titled “Global Constraints”- One PR on the fork base branch
rigel-release. New code sits next to the reaper in server/pipeline. server/queuedoes not change:taskOrderLess,canRunConcurrentand thefifo.processloop stay as they are.- No database migration, no API or web change, no new Go module.
CHANGELOG.mdis not edited. Upstream generates it.- Flag
pending-timeout, envWOODPECKER_PENDING_TIMEOUT, acli.DurationFlag, default6h(D2),0disables. It goes afterorphan-reap-gracein cmd/server/flags.go. - The sweep runs only where the orphan reaper runs: one replica.
- A new file starts with the Apache header and
Copyright 2026 Woodpecker Authors, as in server/pipeline/reap.go. - Table tests with
testify.TestReapOrphanedWorkflowsswapsserver.Configglobals, so its cases do not callt.Parallel. - Existing
TestReapOrphanedWorkflowsrows keep their expectations.
T1 adds the age sweep. T2 adds the live agent match, after T1. One PR holds both.
T1. Time out pending workflows in the reaper
Section titled “T1. Time out pending workflows in the reaper”Files: server/pipeline/reap.go, server/pipeline/pending_timeout.go (new), server/pipeline/reap_test.go, server/pipeline/helper_test.go, server/config.go, cmd/server/flags.go, cmd/server/setup.go, cmd/server/server.go, docs/docs/30-administration/10-configuration/10-server.md.
Interfaces:
func ReapOrphanedWorkflows(ctx context.Context, storage store.Store, now time.Time, grace, pendingTimeout time.Duration) errorfunc reapWorkflow(storage store.Store, workflow *model.Workflow, finished int64, pendingState model.StatusValue, errMsg string) bool
// server/pipeline/pending_timeout.govar pendingWorkflowsTimedOut = promauto.NewCounter(prometheus.CounterOpts{ Namespace: "woodpecker", Name: "pending_workflows_timed_out_total", Help: "Queued workflows ended error because no agent took them within pending-timeout.",})
func runnableSince(p *model.Pipeline, workflows []*model.Workflow, task *model.Task) int64func pendingTimeoutReason(task *model.Task, timeout time.Duration) stringfunc treeDepStatus(task *model.Task, workflows []*model.Workflow) (map[string]model.StatusValue, bool)func finalizeDeadWorkflows(ctx context.Context, storage store.Store, workflows []*model.Workflow, waiting map[string]*model.Task) []*model.Workflowfunc removeStaleTaskRows(ctx context.Context, storage store.Store, rows []*model.Task, loaded map[string]model.StatusValue, waiting map[string]*model.Task, running map[string]struct{})reapWorkflow: a started workflow or step endskilled, as today; one not started endspendingState. A non-emptyerrMsggoes intoErrorand setsFinished. The existing call passesmodel.StatusCanceledand"".treeDepStatusmaps each ID intask.Dependenciesto its workflow’sState, and returnsfalsewhen one is missing orRunning().runnableSincereturns0whentreeDepStatusreturnsfalse, else the largest ofp.Created,p.Reviewedand each dependency’sFinished.finalizeDeadWorkflowsapplies Dependents that can never run to eachpendingworkflow inwaitinguntil a scan finalizes none. A failed write is logged and skipped. It returns the finalized workflows.removeStaleTaskRowsapplies Stale task rows. Terminal ismodel.IsTerminalStatus(state)ormodel.StatusSkipped, as inisTerminalWorkflowState.ErrRecordNotExistis ignored.
In ReapOrphanedWorkflows:
- Next to
queued, buildpending(ID to task) fromqueueInfo.Pending,waitingfromqueueInfo.PendingandqueueInfo.WaitingOnDeps, andrunningfromqueueInfo.Running. WithpendingTimeoutat0, steps 2, 3 and 5 are skipped. - In the
queuedbranch, unlessqueueInfo.Paused: a workflow whose task is inpending, whosetreeDepStatuslets it run, and whoserunnableSinceis above0and beforenow - pendingTimeoutgoes throughreapWorkflow(..., model.StatusError, reason), thenCancelWorkflows. Setchanged, add 1 to the counter, and log. - After the workflow loop, call
finalizeDeadWorkflows. A non-empty result setschanged. Before the loop, record each workflow’s state inloaded. - In the tail, apply the
canceledoverride only when no workflow in the tree iserror. - After the pipeline loop, call
removeStaleTaskRowswithpersistedTasks.
PendingTimeout time.Duration // server/config.go, after OrphanReapGrace
server.Config.Server.PendingTimeout = c.Duration("pending-timeout") // cmd/server/setup.go
pipeline.ReapOrphanedWorkflows(ctx, _store, time.Now(), server.Config.Server.OrphanReapGrace, server.Config.Server.PendingTimeout) // cmd/server/server.goThe docs get ### PENDING_TIMEOUT after ### ORPHAN_REAP_GRACE: the default,
0 disables, and it needs ORPHAN_REAP_INTERVAL above 0.
T1 tests
Section titled “T1 tests”Written first, as TestReapOrphanedWorkflows rows (strict
store_mocks.MockStore, now as clock, 6h timeout, counter read by
testutil.ToFloat64). Rows that time out or finalize:
- A pending
type=linuxtask, pipeline 7 hours old. Workflow and step enderrorwith the reason inErrorandFinishedset.CancelWorkflowsgets["41"]once. The pipeline endserror. The counter rises by 1. - Row 1 on a push event with
StatusAggregateon and theaggregateResilienceForgefake, extended to keep the pipeline it gets. The aggregate is called once with pipeline statuserror. Today’s tail givescanceled, whichconvertStatusposts as success. - Row 1 on a pull event plus a leased sibling in
Info.Running. The pipeline stays open, and the aggregate rolls uperror. - Row 1 plus a default dependent in
WaitingOnDepsand aruns_on: failuredependent. The first endsskippedwith acanceledstep and its ownCancelWorkflowscall. The second is untouched. - Row 1 plus a
runs_on: failuredependent of row 1’s workflow and of a workflow that endedsuccess. It endsskipped. While that second workflow isrunning, it stays queued. - A dead dependent in
Info.Pendingwhose dependency failed 8 hours ago endsskipped, noterror. WorkflowUpdatefails. NoCancelWorkflows, no counter change.CancelWorkflowsfails. The workflow still endserror.- A row in
Info.Pendingwhose workflow issuccessgetsDone(ctx, "41", model.StatusSuccess)and no cancel. - A queued row whose
WorkflowLoadreturnsErrRecordNotExistgetsError; its default dependent endsskipped, not run. - Row 1 plus dependent A of row 1 and dependent B of A. Both end
skippedin the same pass.
Rows that stay queued, each one change from row 1: the task is in
WaitingOnDeps; the task is in Running; a dependency finished 1 hour ago;
the pipeline was reviewed 1 hour ago; the pipeline is 5 hours old;
pendingTimeout is 0; the queue is paused.
TestReapPendingTimeoutNextPass calls ReapOrphanedWorkflows twice on one
set of mocks:
- Pass 1: the dependent’s
WorkflowUpdatefails. Pass 2: its dependency is alreadyerror; it endsskipped, with itsCancelWorkflowscall. - Pass 1:
CancelWorkflowsreturns the persistent delete error. Pass 2:TaskListstill has row41,Infodoes not, andWorkflowLoad(41)returns iterror.TaskDelete("41")is called once.
T2. Live agent match
Section titled “T2. Live agent match”Files: server/model/agent.go, server/scheduler/filter.go, server/pipeline/pending_timeout.go, their tests, and server/rpc/rpc_test.go.
Interfaces:
func (a *Agent) PollLabels() (map[string]string, error)func NewLabelFilter(labels map[string]string) FilterFnfunc loadLiveAgentFilters(storage store.Store, now time.Time, window time.Duration) ([]scheduler.FilterFn, error)func anyAgentTakes(filters []scheduler.FilterFn, task *model.Task) boolPollLabelsbuilds the map of Matching against live agents and returns a new map.loadLiveAgentFilterscallsAgentList(&model.ListOptionsWithAll{All: true})once per pass and keeps agents withNoSchedulefalse andLastContactwithinwindow(D5). On an error, no task times out.- Tests: T1 rows gain an
AgentListmock with no live match;TestAgentPollLabels;TestNewLabelFilter; reap rows where a busy live match stays, aNoSchedulematch and a match last seen beyond the window time out, and ahostnametask and anAgentListerror stay. - Parity test: drive
RPC.Nextwith the labels the agent sends, asTestRPCNextInjectsReadOnlyNetrcdoes, and assert its filter andNewLabelFilter(agent.PollLabels())agree on a task set withouthostname.
- Before deploy: list stored agents (admin
GET /api/agents, 50 per page). Note any withtype=linuxincustom_labelsorfilters, and itslast_contact. No live match confirms the 5 pipelines cannot run. - T1: write the test rows and
TestReapPendingTimeoutNextPass; see them fail. - T1: add pending_timeout.go, change reap.go, add the flag, the wiring
and
PENDING_TIMEOUTin the server configuration docs. - T2: tests first, then the four functions.
-
make lintandmake test-serverpass. - After deploy, within one reaper interval: the
type=linuxpipelines enderror, their required check posts failure, and the counter rises.
Errata
Section titled “Errata”E1 — 2026-10-17 (RIG-5475)
Section titled “E1 — 2026-10-17 (RIG-5475)”Wrong: § “Dependents that can never run” says a dead workflow ends skipped
for every dependency state, and § “Finalizing a timed-out workflow” lists the
log reasons as pending-timeout or dependency-failed.
A dependent whose blocking dependency workflow was deleted ends error, with
Error set to dependency workflow <id> no longer exists, and logs
reason=dependency-deleted. A skipped dependent rolls up as success, so a
deleted dependency would otherwise turn the pipeline check green.
E2 — 2026-10-17 (RIG-5475)
Section titled “E2 — 2026-10-17 (RIG-5475)”Wrong: the clock in § “When a queued workflow times out” uses each
dependency’s Finished as stored.
A terminal dependency stored with Finished 0, such as one skipped without
starting, first gets Finished set to the time the reaper sees it. Without
this, the clock falls back to pipeline.Created, and the dependent could time
out one pass after it becomes runnable.
E3 — 2026-10-17 (RIG-5475)
Section titled “E3 — 2026-10-17 (RIG-5475)”Wrong: Matching against live agents rebuilds every agent with
hostname="*" and matches tasks with it.
A real agent polls with its own hostname. So a task that sets hostname=*
matches an agent only when that agent’s custom labels or server filters set
hostname=*. A concrete hostname value still never times out.