mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The wake queue starts the next run for a task when the task's
current run releases its execution lock.
> - A board Retry saves a receipt with only `{ issueId, taskRetry }`.
This receipt has no saved wake context.
> - When the release drain promotes that receipt, the promoted context
gets `taskKey` from the payload, but it does not get `issueId` or
`taskId`.
> - The new run takes the task's execution lock, but the database saves
it as a company-scoped run with no issue.
> - Low-trust workspace preparation then has no issue reference. The run
fails before provider work with "Low-trust execution issue is outside
the active trust boundary."
> - This pull request restores the issue scope of a payload-only receipt
during promotion.
> - The benefit is that a Retry on a low-trust task runs in its task
boundary and does not fail during setup.
## Linked Issues or Issue Description
**What happened?**
A user clicked Retry on a low-trust task while a run was busy. When the
busy run ended, the release drain promoted the saved Retry receipt. The
promoted run had `issue_id = NULL`, `scope_kind = company`, and a
context snapshot with only `taskKey`, `wakeReason: task_retry`,
`wakeSource`, and `wakeTriggerDetail`. Workspace preparation failed with
`low_trust_boundary_mismatch` (HTTP 422) in the setup phase.
**Expected behavior**
A promoted run for a task must keep that task's issue scope. This is
true also when the deferred receipt has no saved wake context. Workspace
preparation must then evaluate the task's own trust boundary.
**Steps to reproduce**
1. Use a task with a low-trust execution policy whose assignee is an
agent.
2. While a run on that task is busy, click Retry on the task.
3. Let the busy run end. The release drain promotes the deferred Retry
receipt.
4. The promoted run is company-scoped with no `issueId`. It fails in
setup with `low_trust_boundary_mismatch`.
**Paperclip version or commit**
`master` at 51d1d4ce07.
**Agent adapter(s) involved**
Not adapter-specific (core bug).
## What Changed
- `enrichPromotedWakeContext`
(`server/src/modules/wake-queue/domain/context.ts`) now restores
`issueId` and `taskId` from the payload when the deferred context does
not have them. This is the same rule that the original wake enrichment
uses. The function does not overwrite a value that the queued context
already has.
- I added a domain test for the payload-only receipt and for the
no-overwrite rule.
- I added a Postgres adapter test
(`adapters/promoted-retry-scope.test.ts`). It runs the real release
drain with a Retry receipt that has no saved wake context. It checks
that the promoted run is issue-scoped and holds the task's execution
lock. The database trigger `set_heartbeat_run_scope_kind` derives
`issue_id` and `scope_kind` from the restored context `issueId`.
## Verification
- Red before the fix: both new tests fail on unchanged `master`. The
adapter test shows the incident shape: `issueId: null`, `scopeKind:
"company"`, and `taskKey` equal to the issue UUID.
- Green after the fix:
- `pnpm vitest run server/src/modules/wake-queue/` passes (10 files, 289
tests).
- The related suites pass (11 files, 468 tests): `heartbeat/queue`,
`heartbeat/run-control`, `heartbeat/run-lifecycle`,
`deferred-execution-receipt-work`, `execution-lock-orphan-cleanup`,
`heartbeat-comment-wake-batching`, `issue-stale-execution-lock-routes`,
`chat-failed-run-retry-boundary`, `task-retry`,
`execution-control-work`, and `workspace-recovery`.
- Server typecheck (`node server/scripts/typecheck-layers.mjs`) passes.
- I did not run a live low-trust Retry on a deployed stack. The adapter
test uses the real release drain, the real database trigger, and the
persisted receipt shape that `requestTaskRetry` writes.
## Risks
- Low risk. The change only adds `issueId` and `taskId` when they are
missing and the payload has them. Deferred wakes that already have a
queued context do not change.
- The release drain still promotes a board Retry receipt directly. It
does not go through `resumeTaskRetry`. This PR does not change that
path. A promoted Retry therefore does not get `forceFreshSession` or
`taskRetryRequestId`. This behavior existed before this PR. It is a
possible follow-up, not part of this fix.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude
Code with tool use (code search, shell, and test execution).
## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
6.6 KiB
TypeScript
147 lines
6.6 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");
|
|
});
|
|
|
|
it("restores the issue scope of a payload-only receipt without overwriting a queued context", () => {
|
|
const payloadOnly = enrichPromotedWakeContext({
|
|
contextSnapshot: {},
|
|
reason: "task_retry",
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
payload: { issueId: "issue-1", taskRetry: { schema: "paperclip.task-retry.v1" } },
|
|
});
|
|
expect(payloadOnly.contextSnapshot).toMatchObject({
|
|
issueId: "issue-1", taskId: "issue-1", taskKey: "issue-1", wakeReason: "task_retry",
|
|
});
|
|
|
|
const queued = enrichPromotedWakeContext({
|
|
contextSnapshot: { issueId: "issue-1", taskId: "task-1", taskKey: "TASK-1" },
|
|
reason: "issue_commented",
|
|
source: "automation",
|
|
triggerDetail: null,
|
|
payload: { issueId: "issue-2" },
|
|
});
|
|
expect(queued.contextSnapshot).toMatchObject({ issueId: "issue-1", taskId: "task-1", taskKey: "TASK-1" });
|
|
});
|
|
});
|