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" };