mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The heartbeat service releases deferred issue wakes and promotes the next run > - The release logic sat in a large service function, which made its decisions and database effects hard to test > - A promotion race could reopen an issue without creating the promoted run > - Company checks did not protect every read and write, and some readers used a second transaction connection > - This pull request moves the release logic into a layered wake-queue module and closes these race and company-scope defects > - The benefit is clearer decisions, safer writes, and focused tests while existing callers keep the same entry point ## Linked Issues or Issue Description Refs: #10195 ## What Changed - Move the release half of deferred issue execution from `heartbeat.ts` into `server/src/modules/wake-queue/`. - Add pure policy decisions with table-driven tests. - Claim a wake before the reopen write and advance to the next wake when the claim fails. - Add company predicates to guarded reads and writes. - Pass the transaction-scoped issue snapshot to reader ports. - Keep `releaseIssueExecutionAndPromote` as the public wrapper. ## Verification - Run `pnpm check:module-boundaries`. - Run `pnpm exec tsc --noEmit` inside `server/` and compare the result with the known baseline. - Run the wake-queue unit and adapter tests in continuous integration. - Check the promotion-claim ordering, guarded writes, transaction-scoped reads, and cross-company outcomes. - Search the pull request diff for internal issue identifiers. ## Risks - The refactor changes the transaction path for deferred wake release. - A stale or lost wake claim now skips that wake and continues with the next queued wake. - The public wrapper keeps its name and signature, which limits caller risk. - Continuous integration must confirm the full server test suite and build. ## Model Used OpenAI Codex, GPT-5, exact runtime model version not exposed, context window not exposed, with shell, Git, and GitHub tool use. ## 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: Paperclip <noreply@paperclip.ing>
125 lines
5.8 KiB
TypeScript
125 lines
5.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { enrichPromotedWakeContext } from "./context.js";
|
|
|
|
describe("enrichPromotedWakeContext", () => {
|
|
it("clears stale derived comment projections when the canonical id list is empty, but keeps an independent interaction continuation", () => {
|
|
const contextSnapshot: Record<string, unknown> = {
|
|
// Normalization already dropped the canonical wake comment ids upstream
|
|
// of this function; no wakeCommentIds field remains on the snapshot.
|
|
paperclipWake: { commentId: "comment-2" },
|
|
paperclipWakeComment: { id: "comment-2", body: "stale body" },
|
|
paperclipTaskMarkdown: "stale markdown",
|
|
paperclipTaskMarkdownCompact: "stale compact markdown",
|
|
// Independent interaction continuation the promoted run must keep.
|
|
interactionId: "interaction-1",
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
};
|
|
|
|
const result = enrichPromotedWakeContext({
|
|
contextSnapshot,
|
|
reason: "issue_execution_promoted",
|
|
source: "automation",
|
|
triggerDetail: null,
|
|
payload: { mutation: "interaction" },
|
|
});
|
|
|
|
expect(result.contextSnapshot.wakeCommentIds).toBeUndefined();
|
|
expect(result.contextSnapshot.commentId).toBeUndefined();
|
|
expect(result.contextSnapshot.wakeCommentId).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipWake).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipWakeComment).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipTaskMarkdown).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
|
|
// The independent interaction continuation survives the clear.
|
|
expect(result.contextSnapshot.interactionId).toBe("interaction-1");
|
|
expect(result.contextSnapshot.interactionStatus).toBe("accepted");
|
|
});
|
|
|
|
it("sets the canonical id and latest-id fields when the recomputed list has entries, and still clears the stale projections", () => {
|
|
const contextSnapshot: Record<string, unknown> = {
|
|
wakeCommentIds: ["comment-1"],
|
|
paperclipWake: { commentId: "comment-1" },
|
|
paperclipWakeComment: { id: "comment-1", body: "will be rebuilt" },
|
|
paperclipTaskMarkdown: "will be rebuilt",
|
|
paperclipTaskMarkdownCompact: "will be rebuilt",
|
|
};
|
|
|
|
const result = enrichPromotedWakeContext({
|
|
contextSnapshot,
|
|
reason: "issue_execution_promoted",
|
|
source: "automation",
|
|
triggerDetail: null,
|
|
payload: {},
|
|
});
|
|
|
|
expect(result.contextSnapshot.wakeCommentIds).toEqual(["comment-1"]);
|
|
expect(result.contextSnapshot.commentId).toBe("comment-1");
|
|
expect(result.contextSnapshot.wakeCommentId).toBe("comment-1");
|
|
// The derived projections are cleared so the run rebuilds them from the
|
|
// canonical ids instead of carrying forward a queue-time snapshot.
|
|
expect(result.contextSnapshot.paperclipWake).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipWakeComment).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipTaskMarkdown).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
|
|
});
|
|
|
|
it("adds a new id from the payload to the canonical list and clears the stale projections", () => {
|
|
const contextSnapshot: Record<string, unknown> = {
|
|
wakeCommentIds: ["comment-1"],
|
|
paperclipWakeComment: { id: "comment-1", body: "stale body" },
|
|
};
|
|
|
|
const result = enrichPromotedWakeContext({
|
|
contextSnapshot,
|
|
reason: "issue_execution_promoted",
|
|
source: "automation",
|
|
triggerDetail: null,
|
|
payload: { commentId: "comment-2" },
|
|
});
|
|
|
|
expect(result.contextSnapshot.wakeCommentIds).toEqual(["comment-1", "comment-2"]);
|
|
expect(result.contextSnapshot.commentId).toBe("comment-2");
|
|
expect(result.contextSnapshot.wakeCommentId).toBe("comment-2");
|
|
expect(result.contextSnapshot.paperclipWakeComment).toBeUndefined();
|
|
});
|
|
|
|
it("keeps the raw issue, execution-stage, and accepted-plan fields a dispatch rebuild needs, alongside the cleared text projections", () => {
|
|
const contextSnapshot: Record<string, unknown> = {
|
|
wakeCommentIds: ["comment-1"],
|
|
paperclipWake: { commentId: "comment-1" },
|
|
paperclipTaskMarkdown: "stale markdown",
|
|
paperclipTaskMarkdownCompact: "stale compact markdown",
|
|
// Raw context a dispatch-time rebuild reads directly off the promoted
|
|
// run; none of it is derived from the comment ids above, so none of it
|
|
// should be cleared alongside the stale text projections.
|
|
issueId: "issue-1",
|
|
executionStage: { stage: "review" },
|
|
planReviewInteraction: { acceptedTargetRevision: { revisionId: "revision-1" } },
|
|
acceptedPlanWakeRouting: { targetAgentId: "agent-1" },
|
|
workspaceRefreshReason: "accepted_plan_confirmation",
|
|
};
|
|
|
|
const result = enrichPromotedWakeContext({
|
|
contextSnapshot,
|
|
reason: "issue_execution_promoted",
|
|
source: "automation",
|
|
triggerDetail: null,
|
|
payload: {},
|
|
});
|
|
|
|
// The rendered text is cleared; dispatch rebuilds it from the raw fields.
|
|
expect(result.contextSnapshot.paperclipWake).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipTaskMarkdown).toBeUndefined();
|
|
expect(result.contextSnapshot.paperclipTaskMarkdownCompact).toBeUndefined();
|
|
// The raw fields the rebuild needs are untouched.
|
|
expect(result.contextSnapshot.issueId).toBe("issue-1");
|
|
expect(result.contextSnapshot.executionStage).toEqual({ stage: "review" });
|
|
expect(result.contextSnapshot.planReviewInteraction).toEqual({
|
|
acceptedTargetRevision: { revisionId: "revision-1" },
|
|
});
|
|
expect(result.contextSnapshot.acceptedPlanWakeRouting).toEqual({ targetAgentId: "agent-1" });
|
|
expect(result.contextSnapshot.workspaceRefreshReason).toBe("accepted_plan_confirmation");
|
|
});
|
|
});
|