From e2d9027f7e6a866e1bd5527fb6a5fb3472258cda Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Sun, 13 Sep 2026 12:36:28 +0200 Subject: [PATCH] fix(tracker): apply the workflow's on_failure transition when a dispatch fails driveFailed called failRun, which ends the run but leaves the task in its current stage. A task still sitting in a claimable stage is claimed again on the next poll, so a worker that fails every attempt produces an unbounded claim/dispatch/fail loop: a new run and a new workspace per tick, and the ticket never moves. Reproduced against nulltickets with a worker whose dispatch always returns 500 and poll_interval_ms 1000: 10 runs in 20 seconds, task still in its initial stage. After the change: one run, task in the configured failure stage. WorkflowDef already carries on_failure.transition_to and defaults it to "failed"; it was never read on this path. Apply it, and fall back to failRun when no transition is configured or the pipeline rejects the trigger, so the run is closed out either way. This does not add backoff or a dead letter. A pipeline that rejects the configured trigger still loops, and that case now logs a warning naming the trigger and the stage. Co-Authored-By: Claude Opus 5.5 --- src/tracker.zig | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/tracker.zig b/src/tracker.zig index f886ac6..93e86f8 100644 --- a/src/tracker.zig +++ b/src/tracker.zig @@ -1120,9 +1120,43 @@ pub const Tracker = struct { } } - // Original failure path (no retry or max attempts reached) + // Original failure path (no retry or max attempts reached). + // + // failRun ends the run, but it leaves the task in its current stage. + // A task that is still in a claimable stage is claimed again on the + // next poll, so a worker that fails every attempt produces an + // unbounded claim/dispatch/fail loop: a new run row and a new + // workspace per tick, and the ticket never moves. Apply the + // workflow's configured on_failure transition first so the task + // actually leaves the stage. var client = tracker_client.TrackerClient.init(tick_alloc, self.cfg.url orelse "", self.cfg.api_token); - _ = client.failRun(task.run_id, "execution failed", task.lease_token, null) catch {}; + + var transitioned = false; + if (self.workflows.get(task.pipeline_id)) |wf| { + if (wf.on_failure.transition_to.len > 0) { + transitioned = client.transition( + task.run_id, + wf.on_failure.transition_to, + task.lease_token, + null, + task.task_identifier, + task.task_version, + ) catch false; + if (!transitioned) { + log.warn("failure transition '{s}' rejected for task {s}; task stays in stage {s}", .{ + wf.on_failure.transition_to, + task.task_id, + task.task_identifier, + }); + } + } + } + + // Fall back to failing the run when no transition is configured or the + // pipeline rejected the trigger, so the run is still closed out. + if (!transitioned) { + _ = client.failRun(task.run_id, "execution failed", task.lease_token, null) catch {}; + } if (self.cfg.workspace.hooks.after_run) |hook| { _ = workspace_mod.runHook(self.allocator, hook, task.workspace_path, @as(u64, self.cfg.workspace.hook_timeout_ms)) catch false;