diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 404b777d..30921283 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -18,6 +18,7 @@ import { resolveNextSessionState, resolveWorkspaceAfterLowTrustPreflight, resolveRuntimeSessionParamsForWorkspace, + shouldDeferFollowupWakeForSameIssue, stripHostWorkspaceProvisionForLowTrustSandbox, stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForWake, @@ -879,6 +880,52 @@ describe("shouldResetTaskSessionForWake", () => { }); }); +describe("shouldDeferFollowupWakeForSameIssue", () => { + it("defers a same-agent follow-up for mention-style comment wakes while a run is active", () => { + expect( + shouldDeferFollowupWakeForSameIssue({ + activeRunStatus: "running", + isSameExecutionAgent: true, + wakeCommentId: "comment-1", + forceFreshSession: false, + }), + ).toBe(true); + }); + + it("defers a same-agent follow-up when a fresh session is explicitly requested", () => { + expect( + shouldDeferFollowupWakeForSameIssue({ + activeRunStatus: "running", + isSameExecutionAgent: true, + wakeCommentId: null, + forceFreshSession: true, + }), + ).toBe(true); + }); + + it("does not defer when the existing run is only queued", () => { + expect( + shouldDeferFollowupWakeForSameIssue({ + activeRunStatus: "queued", + isSameExecutionAgent: true, + wakeCommentId: null, + forceFreshSession: true, + }), + ).toBe(false); + }); + + it("does not defer normal same-agent wakes without a comment or fresh-session request", () => { + expect( + shouldDeferFollowupWakeForSameIssue({ + activeRunStatus: "running", + isSameExecutionAgent: true, + wakeCommentId: null, + forceFreshSession: false, + }), + ).toBe(false); + }); +}); + describe("deriveTaskKeyWithHeartbeatFallback", () => { it("returns explicit taskKey when present", () => { expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123"); @@ -931,6 +978,21 @@ describe("comment wake batching", () => { expect(merged.wakeCommentId).toBe("comment-2"); expect(merged.paperclipWake).toBeUndefined(); }); + + it("keeps forceFreshSession sticky once any coalesced wake requests it", () => { + const merged = mergeCoalescedContextSnapshot( + { + issueId: "issue-1", + forceFreshSession: true, + }, + { + issueId: "issue-1", + forceFreshSession: false, + }, + ); + + expect(merged.forceFreshSession).toBe(true); + }); }); describe("buildExplicitResumeSessionOverride", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4b0a0c00..a74369ef 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2117,6 +2117,20 @@ export function describeSessionResetReason( return null; } +export function shouldDeferFollowupWakeForSameIssue(input: { + activeRunStatus: string | null | undefined; + isSameExecutionAgent: boolean; + wakeCommentId: string | null | undefined; + forceFreshSession: boolean; +}) { + // A comment follow-up or explicit fresh-session wake needs a new run boundary. + if (!input.isSameExecutionAgent) return false; + if (input.activeRunStatus !== "running") return false; + if (input.wakeCommentId) return true; + if (input.forceFreshSession) return true; + return false; +} + function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; @@ -2366,6 +2380,9 @@ export function mergeCoalescedContextSnapshot( ...existing, ...incoming, }; + if (existing.forceFreshSession === true || incoming.forceFreshSession === true) { + merged.forceFreshSession = true; + } const mergedCommentIds = mergeWakeCommentIds(existing, incoming); if (mergedCommentIds.length > 0) { const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1]; @@ -10238,12 +10255,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) normalizeAgentNameKey(executionAgent?.name); const isSameExecutionAgent = Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey; + const shouldDeferFollowupWake = shouldDeferFollowupWakeForSameIssue({ + activeRunStatus: activeExecutionRun.status, + isSameExecutionAgent, + wakeCommentId, + forceFreshSession: enrichedContextSnapshot.forceFreshSession === true, + }); const shouldQueueFollowupForRunningWake = shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }) && activeExecutionRun.status === "running" && isSameExecutionAgent; - if (isSameExecutionAgent && !shouldQueueFollowupForRunningWake) { + if (isSameExecutionAgent && !shouldDeferFollowupWake && !shouldQueueFollowupForRunningWake) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( activeExecutionRun.contextSnapshot, enrichedContextSnapshot,