Preserve accepted messages for paused recipients (#16679)

Keep accepted deferred requests pending while their recipient is paused and deliver through normal gates after resume. Preserve company, task hold, and cleanup checks.

Verification: 247 focused checks, recursive typecheck, the full CI test and build matrix, and Apex 5/5. The local Git fixture initial-branch limitation is documented in the PR.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-17 18:38:17 -07:00
1 parent 391dd15ff2
commit d30648dee8
11 files changed
+133 -8

No files matched your search

@@ -58,6 +58,7 @@ export type InvokableAgentSnapshot = {
companyId: string;
name: string | null;
invokable: boolean;
paused?: boolean;
};
/** A new heartbeat run reached the queued state and should be published and dispatched. */
@@ -146,6 +146,28 @@ function createFakeRecovery(): RecoveryEscalationPort {
}
describe("releaseIssueExecution", () => {
it.each([false, true])("preserves a paused head without blocking an eligible follower (%s)", async withFollower => {
const paused = wakeCandidate({ id: "paused-wake", wakeReason: "issue_comment_mentioned", queuedCommentIds: ["paused-comment"] });
const ready = wakeCandidate({ id: "ready-wake", agentId: "ready-agent", queuedCommentIds: ["ready-comment"] });
const pending = withFollower ? [paused, ready] : [paused];
const transaction = createFakeTransaction({
findNextDeferredWake: vi.fn(async input => pending.find(wake => !input.excludedWakeIds?.includes(wake.id)) ?? null),
findInvokableAgent: vi.fn(async input => input.agentId === paused.agentId
? { ...AGENT, invokable: false, paused: true } : { ...AGENT, id: ready.agentId }),
getQueuedCommentLiveness: vi.fn(async input => ({ liveNonSelfCommentIds: input.queuedCommentIds, containedSelfAuthoredComment: false })),
});
const release = createReleaseIssueExecution({ issueLock: createFakeIssueLock(createFakeHost(), transaction,
{ ...ISSUE, status: "blocked", assigneeAgentId: ready.agentId }, { ...RUN, status: "succeeded" }), recovery: createFakeRecovery() });
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date(), resumeStrandedQueue: true });
expect(result.outcome.kind).toBe(withFollower ? "promoted" : "released");
expect(transaction.failDeferredWake).not.toHaveBeenCalled();
expect(transaction.cancelDeferredWake).not.toHaveBeenCalled();
expect(transaction.normalizeDeferredWakeCommentIds).not.toHaveBeenCalled();
expect(transaction.findNextDeferredWake).toHaveBeenNthCalledWith(2, expect.objectContaining({ excludedWakeIds: [paused.id] }));
if (withFollower) expect(transaction.finalizePromotedWake).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ wakeId: ready.id }));
else expect(transaction.finalizePromotedWake).not.toHaveBeenCalled();
});
it.each(["closing", "mixed_comments", "explicit_resume", "interaction", "other_source_task", "parent_open"])(
"checks late completed-delegation mentions without dropping independent input (%s)", async (scenario) => {
const originalCommentIds = ["closing-comment", "second-comment"];
@@ -152,12 +152,12 @@ async function runReleaseDrain(
// The `processedWakeIds` guard below makes a break of the invariant
// fail loudly, instead of holding this transaction open forever.
const processedWakeIds = new Set<string>();
const handoffWakeIds: string[] = [];
const preservedWakeIds: string[] = [];
while (true) {
const candidate = await ports.transaction.findNextDeferredWake({
companyId: run.companyId, issueId: issue.id,
...(handoffWakeIds.length ? { excludedWakeIds: handoffWakeIds } : {}),
...(preservedWakeIds.length ? { excludedWakeIds: preservedWakeIds } : {}),
});
if (!candidate) break;
if (processedWakeIds.has(candidate.id)) {
@@ -205,7 +205,7 @@ async function runReleaseDrain(
// The old owner can release before assignment admission adopts these
// exact IDs. Leave its receipt intact, skip it for this drain, and let
// a current-assignee wake behind it proceed.
handoffWakeIds.push(candidate.id);
preservedWakeIds.push(candidate.id);
} else {
// The current owner has finished. An obsolete assignment cannot
// launch another former-owner run or reopen its completed task.
@@ -283,7 +283,7 @@ async function runReleaseDrain(
// A comment-id rewrite cannot change the agent or pause-hold facts already fetched above, so one decision covers both the rewritten and un-rewritten cases.
const wakeOutcome = decideWakeOutcome({
agent: { agentFound: deferredAgent !== null, invokable: deferredAgent?.invokable ?? false },
agent: { agentFound: deferredAgent !== null, invokable: deferredAgent?.invokable ?? false, paused: deferredAgent?.paused === true },
pauseHold: { activePauseHold: pauseHold.activePauseHold, treeHoldInteractionWake: pauseHold.treeHoldInteractionWake },
});
@@ -292,6 +292,11 @@ async function runReleaseDrain(
continue;
}
if (wakeOutcome.kind === "defer_agent_pause") {
preservedWakeIds.push(workingCandidate.id);
continue;
}
if (wakeOutcome.kind === "cancel_pause_hold") {
await ports.transaction.cancelDeferredWake({
companyId: run.companyId,