mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## 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>
167 lines
10 KiB
TypeScript
167 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
|
import { buildExecutionContinuation, currentContinuationOrigins } from "./execution-continuation.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("interaction producer task scope", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-continuation-provenance-");
|
|
db = createDb(database.connectionString);
|
|
}, 30_000);
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
|
|
async function fixture() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), producerIssueId = randomUUID();
|
|
const producerRunId = randomUUID(), interactionId = randomUUID(), targetCommentId = randomUUID(), producerCommentId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Provenance fixture", issuePrefix: `PRV${companyId.slice(0, 8)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Executor", role: "engineer", adapterType: "paperclip_runner" });
|
|
await db.insert(issues).values([
|
|
{ id: issueId, companyId, title: "Target task", status: "in_progress", assigneeAgentId: agentId },
|
|
{ id: producerIssueId, companyId, title: "Producer task", status: "in_progress", assigneeAgentId: agentId },
|
|
]);
|
|
await db.insert(issueComments).values([
|
|
{ id: targetCommentId, companyId, issueId, authorType: "user", authorUserId: "local-board", body: "Keep the target direction." },
|
|
{ id: producerCommentId, companyId, issueId: producerIssueId, authorType: "user", authorUserId: "local-board", body: "Unrelated producer direction." },
|
|
]);
|
|
const producerContext = { issueId: producerIssueId, commentId: producerCommentId };
|
|
await db.insert(heartbeatRuns).values({ id: producerRunId, companyId, agentId, status: "succeeded",
|
|
contextSnapshot: producerContext,
|
|
resultJson: { summary: "Unrelated producer work.", apiToolReceipts: { saved: { state: "completed", operationId: "producer_operation", result: "Unrelated result." } } },
|
|
});
|
|
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId,
|
|
kind: "ask_user_questions", status: "answered", sourceRunId: producerRunId,
|
|
originCommentIds: [producerCommentId, targetCommentId],
|
|
resolvedByUserId: "local-board", resolvedAt: new Date(),
|
|
payload: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "target", label: "Target" }] }] },
|
|
result: { version: 1, answers: [{ questionId: "scope", optionIds: ["target"] }] },
|
|
});
|
|
const build = (context: Record<string, unknown> = {}) => buildExecutionContinuation({
|
|
db, companyId, issueId, agentId, context: { interactionId, wakeReason: "ask_user_questions.answered", ...context },
|
|
summary: null, exposeLowTrustRaw: false,
|
|
});
|
|
return { companyId, agentId, issueId, producerIssueId, producerRunId, interactionId, targetCommentId, producerCommentId, producerContext, build };
|
|
}
|
|
|
|
it("does not copy another task's comments into newly captured interaction origins", async () => {
|
|
const f = await fixture();
|
|
expect(await currentContinuationOrigins(db, f.companyId, f.issueId, f.producerContext)).toEqual([f.targetCommentId]);
|
|
expect(await currentContinuationOrigins(db, f.companyId, f.issueId, { commentId: f.targetCommentId })).toEqual([f.targetCommentId]);
|
|
});
|
|
|
|
it("retains unknown and cross-company origins so dispatch can reject them", async () => {
|
|
const f = await fixture(), other = await fixture(), missing = randomUUID();
|
|
const ids = [missing, "not-a-uuid", other.targetCommentId, f.producerCommentId];
|
|
expect(await currentContinuationOrigins(db, f.companyId, f.issueId, { commentIds: ids }))
|
|
.toEqual([missing, "not-a-uuid", other.targetCommentId, f.targetCommentId]);
|
|
});
|
|
|
|
it("resumes a human answer with legacy producer origins without importing the producer task", async () => {
|
|
const f = await fixture();
|
|
const envelope = await f.build();
|
|
expect(envelope.trigger.sourceRunId).toBe(f.producerRunId);
|
|
expect(envelope.originCommentIds).toEqual([f.targetCommentId]);
|
|
expect(envelope.messages.map(row => row.id)).toEqual([f.targetCommentId]);
|
|
expect(envelope.objective).toBe("Keep the target direction.");
|
|
expect(envelope.humanResponses).toEqual([expect.objectContaining({ id: f.interactionId,
|
|
result: { answers: [{ questionId: "scope", optionIds: ["target"], otherText: undefined }] } })]);
|
|
expect(envelope.completedWork).toBeNull();
|
|
expect(envelope.completedActions).toEqual([]);
|
|
expect(JSON.stringify(envelope)).not.toContain("Unrelated");
|
|
});
|
|
|
|
it("accepts a taskless producer as provenance when the interaction has target-scoped origins", async () => {
|
|
const f = await fixture();
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: {} }).where(eq(heartbeatRuns.id, f.producerRunId));
|
|
await db.update(issueThreadInteractions).set({ originCommentIds: [f.targetCommentId] }).where(eq(issueThreadInteractions.id, f.interactionId));
|
|
const envelope = await f.build();
|
|
expect(envelope.trigger.sourceRunId).toBe(f.producerRunId);
|
|
expect(envelope.completedWork).toBeNull();
|
|
expect(envelope.messages).toHaveLength(1);
|
|
});
|
|
|
|
it.each(["retryOfRunId", "previousRunId", "interruptedRunId"])("prefers explicit %s history to the producer", async (key) => {
|
|
const f = await fixture(), resumeRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: resumeRunId, companyId: f.companyId, agentId: f.agentId,
|
|
status: "interrupted", contextSnapshot: { issueId: f.issueId, commentId: f.targetCommentId },
|
|
resultJson: { summary: "Target work.", apiToolReceipts: { saved: { state: "completed", operationId: "target_operation", result: "Target result." } } },
|
|
});
|
|
const envelope = await f.build({ [key]: resumeRunId });
|
|
expect(envelope.trigger.sourceRunId).toBe(resumeRunId);
|
|
expect(envelope.completedWork).toBe("Target work.");
|
|
expect(envelope.completedActions).toEqual([expect.objectContaining({ runId: resumeRunId, operationId: "target_operation" })]);
|
|
expect(envelope.originCommentIds).toEqual([f.targetCommentId]);
|
|
expect(JSON.stringify(envelope)).not.toContain("Unrelated");
|
|
});
|
|
|
|
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 } }))
|
|
.rejects.toThrow("continuation_user_authorization_missing");
|
|
});
|
|
|
|
it.each(["sourceCommentId", "commentId"])("rejects an explicit foreign %s even when it matches the producer", async (key) => {
|
|
const f = await fixture();
|
|
if (key === "sourceCommentId") await db.update(issueThreadInteractions)
|
|
.set({ sourceCommentId: f.producerCommentId }).where(eq(issueThreadInteractions.id, f.interactionId));
|
|
await expect(f.build(key === "commentId" ? { commentId: f.producerCommentId } : {}))
|
|
.rejects.toThrow("continuation_source_context_missing");
|
|
});
|
|
|
|
it.each(["missing", "cross-company", "unrecorded", "other-task"])("rejects %s origins instead of silently discarding them", async (kind) => {
|
|
const f = await fixture();
|
|
let invalidId = randomUUID();
|
|
if (kind === "cross-company") invalidId = (await fixture()).targetCommentId;
|
|
if (kind === "unrecorded") {
|
|
invalidId = f.producerCommentId;
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.producerIssueId } }).where(eq(heartbeatRuns.id, f.producerRunId));
|
|
} else {
|
|
if (kind === "other-task") {
|
|
const thirdIssueId = randomUUID();
|
|
await db.insert(issues).values({ id: thirdIssueId, companyId: f.companyId, title: "Unrelated third task" });
|
|
await db.insert(issueComments).values({ id: invalidId, companyId: f.companyId, issueId: thirdIssueId, body: "Third task." });
|
|
}
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { ...f.producerContext, commentIds: [invalidId] } }).where(eq(heartbeatRuns.id, f.producerRunId));
|
|
}
|
|
await db.update(issueThreadInteractions).set({ originCommentIds: [invalidId] }).where(eq(issueThreadInteractions.id, f.interactionId));
|
|
await expect(f.build()).rejects.toThrow("continuation_source_context_missing");
|
|
});
|
|
|
|
it("rejects a producer outside the company", async () => {
|
|
const f = await fixture(), other = await fixture();
|
|
await db.update(issueThreadInteractions).set({ sourceRunId: other.producerRunId }).where(eq(issueThreadInteractions.id, f.interactionId));
|
|
await expect(f.build()).rejects.toThrow("continuation_source_context_missing");
|
|
});
|
|
|
|
it("rejects a missing explicit resume instead of falling back to the producer", async () => {
|
|
const f = await fixture();
|
|
await expect(f.build({ retryOfRunId: randomUUID() })).rejects.toThrow("continuation_source_context_missing");
|
|
});
|
|
|
|
it("rejects a missing target origin even when legacy producer origins are recoverable", async () => {
|
|
const f = await fixture();
|
|
await db.delete(issueComments).where(eq(issueComments.id, f.targetCommentId));
|
|
await expect(f.build()).rejects.toThrow("continuation_source_context_missing");
|
|
});
|
|
});
|