diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e3c3b34454..cbeb0978f2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -9334,6 +9334,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionState: issues.executionState, monitorNextCheckAt: issues.monitorNextCheckAt, projectId: issues.projectId, + originKind: issues.originKind, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))) diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 9cfe392197..193782770d 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -58,6 +58,7 @@ import { FINISH_SUCCESSFUL_RUN_HANDOFF_REASON, SUCCESSFUL_RUN_MISSING_STATE_REASON, buildSuccessfulRunHandoffExhaustedNotice, + isPluginManagedIssueLifecycle, noticeMetadataReferencesRecoveryAction, type SuccessfulRunHandoffNotice, } from "./successful-run-handoff.js"; @@ -4116,6 +4117,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun); if (handoffEvidence) { + if (isPluginManagedIssueLifecycle(issue)) { + result.skipped += 1; + continue; + } if (!handoffEvidence.exhausted) { result.skipped += 1; continue; diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 552ec84c8e..936aca4564 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -11,6 +11,7 @@ import { decideSuccessfulRunHandoff, isIdempotentFinishSuccessfulRunHandoffWakeStatus, isSuccessfulRunHandoffValidPathSkip, + isPluginManagedIssueLifecycle, isSuccessfulRunHandoffRequiredNoticeBody, noticeMetadataReferencesRecoveryAction, } from "./successful-run-handoff.js"; @@ -244,6 +245,35 @@ describe("successful run handoff decision", () => { }); }); + it("does not queue when a plugin owns the issue's lifecycle", () => { + expect(decide({ issue: { ...issue, originKind: "plugin:paperclip.workflow-engine" } as any })).toEqual({ + kind: "skip", + reason: "issue lifecycle is owned by a plugin", + }); + expect(decide({ issue: { ...issue, originKind: "plugin:paperclip.workflow-engine:advance" } as any })).toEqual({ + kind: "skip", + reason: "issue lifecycle is owned by a plugin", + }); + }); + + it("still queues for non-plugin origin kinds", () => { + expect(decide({ issue: { ...issue, originKind: "manual" } as any }).kind).toBe("enqueue"); + expect(decide({ issue: { ...issue, originKind: null } as any }).kind).toBe("enqueue"); + }); + + describe("isPluginManagedIssueLifecycle", () => { + it("is true for any plugin: prefixed origin kind", () => { + expect(isPluginManagedIssueLifecycle({ originKind: "plugin:paperclip.workflow-engine" })).toBe(true); + expect(isPluginManagedIssueLifecycle({ originKind: "plugin:paperclip.workflow-engine:advance" })).toBe(true); + }); + + it("is false for non-plugin or missing origin kinds", () => { + expect(isPluginManagedIssueLifecycle({ originKind: "manual" })).toBe(false); + expect(isPluginManagedIssueLifecycle({ originKind: null })).toBe(false); + expect(isPluginManagedIssueLifecycle({})).toBe(false); + }); + }); + it("does not queue when a successful run records an accepted next-action path", () => { expect(decide({ issue: { ...issue, status: "in_review" } as any })).toEqual({ kind: "skip", diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index fa8bc04a1f..0c5943606a 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -49,6 +49,22 @@ export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status); } +/** + * A plugin (e.g. a graph/workflow engine) owns this issue's lifecycle and may + * legitimately hold it at `in_progress` for a long time — e.g. an anchor issue + * parked at a fan-out node waiting on spawned child issues. Generic handoff/stranded- + * issue recovery has no way to know that, so treating it as a missing disposition + * repeatedly nags the agent for a "disposition" it has no valid way to give: the + * agent's own status change gets reverted by the plugin's own enforcement on the next + * event, which re-triggers the exact same recovery again — an unbounded, real-cost + * retry loop with no possible resolution. Every recovery path that can escalate or + * nag based on "issue is stuck in_progress" must consult this first and leave + * plugin-managed issues to the plugin's own recovery/enforcement path instead. + */ +export function isPluginManagedIssueLifecycle(issue: { originKind?: string | null }) { + return Boolean(issue.originKind?.startsWith("plugin:")); +} + type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; type IssueRow = Pick< typeof issues.$inferSelect, @@ -57,10 +73,12 @@ type IssueRow = Pick< | "identifier" | "title" | "description" + | "originKind" | "status" | "assigneeAgentId" | "assigneeUserId" | "executionState" + | "originKind" >; type AgentRow = Pick; type NoticeIssue = Pick; @@ -453,6 +471,9 @@ export function decideSuccessfulRunHandoff(input: { if (issue.assigneeUserId) return { kind: "skip", reason: "issue is human-owned" }; if (issue.status !== "in_progress") return { kind: "skip", reason: `issue status ${issue.status} is a valid disposition` }; if (issue.executionState) return { kind: "skip", reason: "issue has execution policy state" }; + if (isPluginManagedIssueLifecycle(issue)) { + return { kind: "skip", reason: "issue lifecycle is owned by a plugin" }; + } if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { return { kind: "skip", reason: `agent status ${agent.status} is not invokable` }; }