Defer same-issue forceFreshSession wakes into follow-up runs (#4080)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The heartbeat service governs how agent wake events get queued,
deferred, or folded into the currently-running adapter run
> - `forceFreshSession: true` wakes on a same-agent/same-issue path get
silently folded into the active run, so callers can never request a true
cold-start follow-up
> - This breaks phased workflows that need to drop a poisoned session
and restart cleanly on the same issue without bouncing to another agent
> - This PR extracts the existing same-issue follow-up decision into
`shouldDeferFollowupWakeForSameIssue` and extends it to also defer
`forceFreshSession: true` wakes into a follow-up run boundary
> - The benefit is that `forceFreshSession` now behaves as documented:
it actually starts a fresh session, even when the wake targets the same
agent/issue/runtime that is currently executing

## Linked Issues or Issue Description

**What happened?**

A wake event posted with `forceFreshSession: true` against an issue
whose current adapter run is still `running` on the same execution agent
is silently coalesced into that in-flight run instead of starting a cold
session. Callers that explicitly request a fresh-session reset see no
behavior change until the run naturally completes.

**Expected behavior**

`forceFreshSession: true` should always force a fresh session start,
even when the wake targets the same agent/issue that is currently
executing. The wake should defer into a follow-up run boundary if the
current run is still in-flight.

**Steps to reproduce**

1. Start an adapter run for some issue.
2. While the run is still `running`, post a wake event for the same
issue/agent with `forceFreshSession: true`.
3. Observe: the active run continues without resetting the session; the
fresh-session signal is dropped.

## What Changed

- Extracted same-issue follow-up decision into exported helper
`shouldDeferFollowupWakeForSameIssue` in
`server/src/services/heartbeat.ts`
- Extended that helper so `forceFreshSession: true` (not only
`wakeCommentId`) defers into a follow-up run when the current run is
still `running` for the same execution agent
- Added stickiness to `mergeCoalescedContextSnapshot`: if either side of
a wake-merge has `forceFreshSession: true`, the merged snapshot keeps it
set so it is not silently dropped while queued wakes coalesce
- Added five unit tests in `heartbeat-workspace-session.test.ts`
covering each decision branch of the helper

## Verification

- `pnpm --filter @paperclipai/server test
src/__tests__/heartbeat-workspace-session.test.ts`
- `pnpm --filter @paperclipai/server typecheck`

## Risks

Low. Behavior change only affects the narrow case where a
same-agent/same-issue wake carries `forceFreshSession: true` while the
active run is still `running`. Other wake paths (cross-agent,
queued/failed runs) are untouched. The helper extraction is a pure
refactor preserving the prior comment-wake deferral.

## Model Used

Claude (Opus 4.7) — extended thinking enabled, used to extract the
helper, extend the deferral condition to cover `forceFreshSession`, and
write unit coverage.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (N/A — no UI changes)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (in progress)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
xidui
2026-06-09 17:09:02 -07:00
committed by GitHub
parent fae7e920a9
commit 5d315ab778
2 changed files with 86 additions and 1 deletions
@@ -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", () => {
+24 -1
View File
@@ -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<string, unknown> | 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,