From ac3dbba406e34dad714ba4b5923e3033ec7026fa Mon Sep 17 00:00:00 2001 From: Lana Date: Tue, 11 Aug 2026 23:44:21 +0300 Subject: [PATCH] fix(recovery): skip successful-run handoff for routine executions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routine execution issues (originKind=routine_execution) stay in_progress between cron fires by design — one open issue is coalesced across dispatches via issues_open_routine_execution_uq. decideSuccessfulRunHandoff treated that steady state as a missing disposition, enqueued a corrective handoff that exhausted after one attempt, and left the issue blocked with a source_scoped_recovery_action — re-waking the agent on every successful run (observed at ~27 runs/hour on one orphan-check routine, which additionally tripped a false productivity review). Exempt routine_execution issues from the handoff, mirroring the existing issue-monitor maintenance skip; the routine lifecycle owns their disposition. Adds a regression test that fails without the guard. --- server/src/services/heartbeat.ts | 1 + .../services/recovery/successful-run-handoff.test.ts | 12 ++++++++++++ .../src/services/recovery/successful-run-handoff.ts | 10 ++++++++++ 3 files changed, 23 insertions(+) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ff40d2f4d2..3733f63b4b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -9235,6 +9235,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeUserId: issues.assigneeUserId, executionState: issues.executionState, monitorNextCheckAt: issues.monitorNextCheckAt, + originKind: issues.originKind, projectId: issues.projectId, }) .from(issues) diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 5aa042b041..9b7cd4e4ae 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -346,6 +346,18 @@ describe("successful run handoff decision", () => { }); }); + it("does not queue for routine execution issues left in_progress between fires", () => { + // A successful orphan-check routine run left its coalesced routine_execution + // issue in `in_progress`, and the handoff wrongly treated that steady state + // as a missing disposition -> blocked + recovery loop. + expect(decide({ + issue: { ...issue, originKind: "routine_execution" } as any, + })).toEqual({ + kind: "skip", + reason: "routine execution owns its own disposition", + }); + }); + it("does not queue for successful comment-driven wakes", () => { expect(decide({ run: { diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index e6a3533586..3a3015c8a2 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -30,6 +30,12 @@ export const SUCCESSFUL_RUN_HANDOFF_OPTIONS = [ "delegate_or_continue_from_checkpoint", ] as const; +// Routine execution issues stay `in_progress` between cron fires by design — one +// open issue is coalesced across dispatches (see issues_open_routine_execution_uq). +// Their disposition is owned by the routine lifecycle, not by the missing-disposition +// handoff, so a successful routine run must not be treated as a missing next step. +const ROUTINE_EXECUTION_ORIGIN_KIND = "routine_execution"; + const PRODUCTIVE_SUCCESS_LIVENESS_STATES = new Set([ "advanced", "completed", @@ -61,6 +67,7 @@ type IssueRow = Pick< | "assigneeAgentId" | "assigneeUserId" | "executionState" + | "originKind" >; type AgentRow = Pick; type NoticeIssue = Pick; @@ -429,6 +436,9 @@ export function decideSuccessfulRunHandoff(input: { return { kind: "skip", reason: "missing issue comment retry owns the next action" }; } if (!issue) return { kind: "skip", reason: "issue not found" }; + if (issue.originKind === ROUTINE_EXECUTION_ORIGIN_KIND) { + return { kind: "skip", reason: "routine execution owns its own disposition" }; + } if (!agent) return { kind: "skip", reason: "agent not found" }; if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) { return { kind: "skip", reason: "company scope mismatch" };