fix(continuation): degrade a stale interruptedRunId hint instead of failing the wake (#14807)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A wake builds its continuation envelope from a chain of
resume-source run ids: explicit user resume, retry chain, previous run,
then the interruptedRunId hint
> - interruptedRunId is a best-effort hint written when a run is
interrupted, and it is not cleared when an issue is reassigned to
another task
> - When the hint names another task's run (or a deleted row), the
envelope build threw continuation_source_context_missing, so the new
assignee's wake could never continue the work
> - This pull request degrades a stale interruptedRunId hint to "no
resume source" when it is the only source tier and does not resolve to
this task, while explicit user resume and retry chains stay fail-closed
> - The benefit is that cross-issue reassigns no longer brick an issue's
continuation wakes

## Linked Issues or Issue Description

Fixes #14723

## What Changed

- `server/src/services/execution-continuation.ts`: when
`interruptedRunId` is the only resume-source tier and the referenced run
is missing or belongs to another issue, continue without a resume source
instead of throwing `continuation_source_context_missing`
- The degraded envelope reports `trigger.sourceRunId: null` and no
longer re-echoes the stale `interruptedRunId` into the new envelope (so
the next wake does not inherit the poisoned hint)
- Explicit user resume (`explicitUserContinuation.previousRunId`),
`retryOfRunId`, and `previousRunId` tiers keep the existing fail-closed
throw (`continuation_user_authorization_missing` for explicit user
resume)
- `server/src/services/execution-continuation.test.ts`: the "rejects
handoff history from a different task" test is rewritten to the intended
degraded behavior, plus a deleted-row case and a fail-closed control
test for explicit user resume

## Verification

- `cd server && npx vitest run
src/services/execution-continuation.test.ts` — 35/35 pass (33 prior
tests plus 2 new; 1 existing test rewritten to the new intended
behavior)
- `npx tsc --noEmit` reports no errors in the touched files
- `git diff --check` is clean

Manual reasoning check: the degrade branch only fires when
`interruptedRunId` is the sole defined tier (`explicitUserSource`,
`retryOfRunId`, `previousRunId` all null) and the run does not resolve
to this task. Every other tier keeps the previous throw, covered by the
new control test.

## Risks

- Behavioral change: a wake that previously failed with
`continuation_source_context_missing` for a stale interrupted-only hint
now continues without resume history. This is the intended fix per the
issue; the fail-closed invariant is preserved for all user-driven and
retry source tiers
- The `interruptedRunId` no longer propagates into the envelope when
stale, so downstream consumers no longer see the stale id (they would
previously only see it when the envelope build succeeded, which it never
did for this case)

## Model Used

Codex (OpenAI Codex CLI, GPT-5 based coding agent, repository-aware tool
use, ~1M context window, extended reasoning), assisted by three
read-only explorer sub-agents for issue triage and duplicate checking.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] 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: 馨冉 <xinxincui239@gmail.com>
This commit is contained in:
馨冉and馨冉 authored and GitHub committed 2026-10-26 20:48:14 -07:00
1 parent ea727dc3c2
commit a02be1efc3
3 files changed
+69 -12

No files matched your search

@@ -98,11 +98,22 @@ const support = await getEmbeddedPostgresTestSupport();
expect(JSON.stringify(envelope)).not.toContain("Unrelated");
});
it.each(["retryOfRunId", "previousRunId", "interruptedRunId"])("rejects explicit %s history from another task", async (key) => {
it.each(["retryOfRunId", "previousRunId"])("rejects explicit %s history from another task", async (key) => {
const f = await fixture();
await expect(f.build({ [key]: f.producerRunId })).rejects.toThrow("continuation_source_context_missing");
});
it("degrades a stale interruptedRunId hint from another task and keeps producer attribution", async () => {
// interruptedRunId is a best-effort hint, not explicit user history: when
// it names another task's run the wake continues without that resume
// source, and an interaction wake falls back to its producer.
const f = await fixture();
const envelope = await f.build({ interruptedRunId: f.producerRunId });
expect(envelope.trigger.sourceRunId).toBe(f.producerRunId);
expect(envelope.completedWork).toBeNull();
expect(envelope).not.toHaveProperty("interruptedRunId");
});
it("retains the explicit user continuation authorization requirement", async () => {
const f = await fixture();
await expect(f.build({ explicitUserContinuation: { previousRunId: f.producerRunId } }))
@@ -193,14 +193,41 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
it("rejects handoff history from a different task", async () => {
it("degrades a stale interrupted-run hint from a different task instead of failing the wake", async () => {
// Cross-issue reassigns can leave interruptedRunId pointing at another
// task's run. The hint is best-effort: the wake must continue without a
// resume source instead of throwing continuation_source_context_missing.
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: randomUUID() } }).where(eq(heartbeatRuns.id, runId));
try {
await expectMissingContinuationContext(
() => buildExecutionContinuation({ db, companyId, issueId, agentId,
context: { interruptedRunId: runId }, summary: null, exposeLowTrustRaw: false }),
);
const envelope = await buildExecutionContinuation({ db, companyId, issueId, agentId,
context: { interruptedRunId: runId, wakeReason: "issue_assigned" }, summary: null, exposeLowTrustRaw: false });
expect(envelope.trigger.sourceRunId).toBeNull();
expect(envelope).not.toHaveProperty("interruptedRunId");
expect(envelope.objective).toBe("Focus the Gmail summary on launch decisions.");
} finally {
await db.update(heartbeatRuns).set({ contextSnapshot: source.contextSnapshot }).where(eq(heartbeatRuns.id, runId));
}
});
it("degrades an interrupted-run hint whose run row no longer exists", async () => {
const missingRunId = randomUUID();
const envelope = await buildExecutionContinuation({ db, companyId, issueId, agentId,
context: { interruptedRunId: missingRunId, wakeReason: "issue_assigned" }, summary: null, exposeLowTrustRaw: false });
expect(envelope.trigger.sourceRunId).toBeNull();
expect(envelope).not.toHaveProperty("interruptedRunId");
});
it("still rejects an explicit user resume from a different task", async () => {
// Fail-closed stays in force for explicit user resume history; only the
// best-effort interruptedRunId hint degrades.
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: randomUUID() } }).where(eq(heartbeatRuns.id, runId));
try {
await expect(
buildExecutionContinuation({ db, companyId, issueId, agentId,
context: { explicitUserContinuation: { previousRunId: runId }, wakeReason: "user_resume" }, summary: null, exposeLowTrustRaw: false }),
).rejects.toThrow("continuation_user_authorization_missing");
} finally {
await db.update(heartbeatRuns).set({ contextSnapshot: source.contextSnapshot }).where(eq(heartbeatRuns.id, runId));
}
+25 -6
View File
@@ -197,25 +197,44 @@ export async function buildExecutionContinuation(input: {
);
const explicitContinuation = object(input.context.explicitUserContinuation);
const explicitUserSource = string(explicitContinuation.previousRunId);
const interruptedSource = string(input.context.interruptedRunId);
const resumeSourceRunId =
explicitUserSource ??
string(input.context.retryOfRunId) ??
string(input.context.previousRunId) ??
string(input.context.interruptedRunId);
interruptedSource;
const producerRunId = triggerInteraction?.sourceRunId ?? null;
const sourceRunId = resumeSourceRunId ?? producerRunId;
const requestedSourceRunId = resumeSourceRunId ?? producerRunId;
const loadRun = async (id: string) => (await db
.select({ context: heartbeatRuns.contextSnapshot, result: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, id)))
)[0];
const candidate = sourceRunId ? await loadRun(sourceRunId) : null;
const candidate = requestedSourceRunId ? await loadRun(requestedSourceRunId) : null;
// An interaction producer is provenance. Explicit resume history must still
// belong to this task, and only task-scoped content can enter the envelope.
const sourceRun = object(candidate?.context).issueId === issueId ? candidate : null;
if ((sourceRunId && !candidate) || (resumeSourceRunId && !sourceRun))
// interruptedRunId is a best-effort hint written when a run is interrupted.
// After a cross-issue reassign it can name another task's run or a deleted
// row. Unlike an explicit user resume or a retry chain, a hint that does
// not resolve to this task must not fail the whole wake: degrade to "no
// resume source" and continue.
const staleInterruptedSource =
interruptedSource !== null
&& resumeSourceRunId === interruptedSource
&& explicitUserSource === null
&& string(input.context.retryOfRunId) === null
&& string(input.context.previousRunId) === null
&& sourceRun === null;
// A degraded hint falls back to the interaction producer so interaction
// wakes keep their source attribution.
const sourceRunId = staleInterruptedSource ? (producerRunId ?? null) : requestedSourceRunId;
if (
!staleInterruptedSource
&& ((requestedSourceRunId && !candidate) || (resumeSourceRunId && !sourceRun))
)
throw new Error(explicitUserSource ? "continuation_user_authorization_missing" : "continuation_source_context_missing");
const producer = producerRunId === sourceRunId ? candidate
const producer = producerRunId === requestedSourceRunId ? candidate
: producerRunId ? await loadRun(producerRunId) : null;
if (producerRunId && !producer) throw new Error("continuation_source_context_missing");
const producerIssueId = string(object(producer?.context).issueId);
@@ -431,7 +450,7 @@ export async function buildExecutionContinuation(input: {
if (!predecessor || !authorization || explicitUserSource !== sourceRunId)
throw new Error("continuation_user_authorization_missing");
}
const interruptedRunId = explicitUserSource ?? string(input.context.interruptedRunId) ?? (lastTerminal && lastTerminal.status !== "succeeded" &&
const interruptedRunId = explicitUserSource ?? (staleInterruptedSource ? undefined : interruptedSource) ?? (lastTerminal && lastTerminal.status !== "succeeded" &&
(hasConversationContinuationPolicy(lastTerminal.result) ||
lastTerminal.status === "interrupted" || lastTerminal.errorCode === "process_lost")
? lastTerminal.id : undefined);