fix(recovery): skip successful-run handoff for routine executions

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.
This commit is contained in:
Lana 2026-08-11 23:44:21 +03:00
parent 2494a2a0fe
commit ac3dbba406
3 changed files with 23 additions and 0 deletions

View File

@ -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)

View File

@ -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: {

View File

@ -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<RunLivenessState>([
"advanced",
"completed",
@ -61,6 +67,7 @@ type IssueRow = Pick<
| "assigneeAgentId"
| "assigneeUserId"
| "executionState"
| "originKind"
>;
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
@ -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" };