diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 01f0fa94..dbc8f56a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4987,6 +4987,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) existingWake, budgetBlock, pauseHold, + activeRoutineContinuation, ] = await Promise.all([ issue ? db @@ -5112,6 +5113,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issue ? treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id) : Promise.resolve(null), + issue + ? db + .select({ id: routines.id }) + .from(routines) + .where( + and( + eq(routines.companyId, issue.companyId), + eq(routines.parentIssueId, issue.id), + eq(routines.status, "active"), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null) + : Promise.resolve(null), ]); const decision = decideSuccessfulRunHandoff({ @@ -5127,6 +5142,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) hasExplicitBlockerPath: Boolean(explicitBlocker), hasOpenRecoveryIssue: Boolean(openRecoveryIssue), hasPauseHold: Boolean(pauseHold), + hasActiveRoutineContinuation: Boolean(activeRoutineContinuation), budgetBlocked: Boolean(budgetBlock), idempotentWakeExists: Boolean(existingWake), }); diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index e632d974..184930b1 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -52,6 +52,7 @@ function decide(overrides: Partial hasExplicitBlockerPath: false, hasOpenRecoveryIssue: false, hasPauseHold: false, + hasActiveRoutineContinuation: false, budgetBlocked: false, idempotentWakeExists: false, ...overrides, @@ -130,6 +131,29 @@ describe("successful run handoff decision", () => { }); }); + it("does not queue when the issue is the recurring parent of an active routine", () => { + expect(decide({ hasActiveRoutineContinuation: true })).toEqual({ + kind: "skip", + reason: "active routine continuation owns the next action", + }); + expect(decide({ + hasActiveRoutineContinuation: true, + detectedProgressSummary: null, + livenessState: null, + })).toEqual({ + kind: "skip", + reason: "active routine continuation owns the next action", + }); + expect(decide({ + hasActiveRoutineContinuation: true, + livenessState: "advanced", + detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)", + })).toEqual({ + kind: "skip", + reason: "active routine continuation owns the next action", + }); + }); + it("does not queue when a successful run has no progress signal", () => { expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({ kind: "skip", diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 4e322c56..5ac73473 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -350,6 +350,7 @@ export function decideSuccessfulRunHandoff(input: { hasExplicitBlockerPath: boolean; hasOpenRecoveryIssue: boolean; hasPauseHold: boolean; + hasActiveRoutineContinuation: boolean; budgetBlocked: boolean; idempotentWakeExists: boolean; }): SuccessfulRunHandoffDecision { @@ -376,6 +377,9 @@ export function decideSuccessfulRunHandoff(input: { if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { return { kind: "skip", reason: `agent status ${agent.status} is not invokable` }; } + if (input.hasActiveRoutineContinuation) { + return { kind: "skip", reason: "active routine continuation owns the next action" }; + } if (!isProductiveSuccessfulRun(input)) { return { kind: "skip", reason: "successful run did not produce handoff-relevant progress" }; }