refactor(server): move the queued-comment queue mutations into the wake-queue module (#13145)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server coordinates issue execution and agent wake events
> - Queued comment mutations belong to the wake queue that owns their
state
> - Route-local database writes split queue rules across two layers
> - This pull request moves those mutations into the wake-queue module
and keeps route authorization and response mapping
> - The benefit is one transaction boundary with company-scoped writes
and a shared checked response contract

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The queued-comment edit, reorder, and discard endpoints write queue
state directly from the route layer.

**Subsystem affected**

server/ — REST API and orchestration services.

**Current behavior**

The route layer owns database transactions, locks, queue writes, and
wake-row writes for queued comments.

**Proposed behavior**

The wake-queue module owns these operations. The routes keep
authorization, input checks, error mapping, and response mapping.

**Reason and benefit**

The module gives all queued-comment callers one transaction boundary and
applies company predicates to every adapter read and write.

**Breaking changes**

None. The endpoints keep their existing paths and response behavior.

## What Changed

- Move queued-comment edit, reorder, and discard operations into the
wake-queue module.
- Add company predicates to seven queue writes.
- Use the shared queue contract type for mutation responses.
- Add module tests and route tests for the moved operations.

## Verification

- `server/src/modules/wake-queue`: 128 tests pass across 6 files.
- `server/src/__tests__/issue-queued-comments-routes.test.ts`: 19 tests
pass.
- The server TypeScript check reports the same 141 pre-existing errors
before and after this change.
- GitHub Actions must pass the required pull-request checks.

## Risks

The change moves transaction and lock ownership across module
boundaries. The new adapter, use-case, and route tests cover the moved
behavior. No database schema changes occur.

## Model Used

OpenAI Codex, GPT-5, current agent runtime, tool use and code review
support.

## 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>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-09-10 08:41:59 -07:00
1 parent 889947c238
commit 0d8bbf7cf4
12 files changed
+2077 -407

No files matched your search

@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
import {
decidePreDrain,
decideQueuedCommentAction,
decideQueuedCommentActorOwnsEntry,
decideQueuedCommentReorder,
decideQueuedCommentWakeLookup,
decideReleaseRecovery,
decideWakeAdmission,
decideWakeOutcome,
@@ -10,6 +13,9 @@ import {
type DeferredWakeQueuedCommentFacts,
type ImmediateRecoveryContextLabels,
type PreDrainFacts,
type QueuedCommentActorOwnershipFacts,
type QueuedCommentReorderFacts,
type QueuedCommentWakeLookupFacts,
type ReleaseRecoveryFacts,
type WakeAdmissionFacts,
} from "./policy.js";
@@ -544,3 +550,166 @@ describe("decideWakeAdmission", () => {
});
}
});
describe("decideQueuedCommentWakeLookup", () => {
const baseFacts: QueuedCommentWakeLookupFacts = {
wakePresent: true,
wakeIssueIdMatches: true,
hasQueuedCommentIds: true,
wakeStatus: "deferred_issue_execution",
wakeHasRunId: false,
};
const cases: Array<{
name: string;
facts: QueuedCommentWakeLookupFacts;
expected: ReturnType<typeof decideQueuedCommentWakeLookup>;
}> = [
{
name: "not_pending: no wake row was found",
facts: { ...baseFacts, wakePresent: false },
expected: { kind: "not_pending" },
},
{
name: "not_pending: the wake's payload names a different issue",
facts: { ...baseFacts, wakeIssueIdMatches: false },
expected: { kind: "not_pending" },
},
{
name: "not_pending: the wake's payload carries no queued comment ids",
facts: { ...baseFacts, hasQueuedCommentIds: false },
expected: { kind: "not_pending" },
},
{
name: "deferred: the wake is still waiting behind an active execution run",
facts: baseFacts,
expected: { kind: "deferred" },
},
{
name: "check_queue_run: the wake is queued and carries a linked run id",
facts: { ...baseFacts, wakeStatus: "queued", wakeHasRunId: true },
expected: { kind: "check_queue_run" },
},
{
name: "not_pending: the wake is queued but carries no linked run id",
facts: { ...baseFacts, wakeStatus: "queued", wakeHasRunId: false },
expected: { kind: "not_pending" },
},
{
name: "already_dispatching: the wake was claimed",
facts: { ...baseFacts, wakeStatus: "claimed" },
expected: { kind: "already_dispatching" },
},
{
name: "already_dispatching: the wake is running",
facts: { ...baseFacts, wakeStatus: "running" },
expected: { kind: "already_dispatching" },
},
{
name: "already_dispatching: the wake succeeded and still carries a run id",
facts: { ...baseFacts, wakeStatus: "succeeded", wakeHasRunId: true },
expected: { kind: "already_dispatching" },
},
{
name: "already_dispatching: the wake failed and still carries a run id",
facts: { ...baseFacts, wakeStatus: "failed", wakeHasRunId: true },
expected: { kind: "already_dispatching" },
},
{
name: "not_pending: the wake succeeded but carries no run id",
facts: { ...baseFacts, wakeStatus: "succeeded", wakeHasRunId: false },
expected: { kind: "not_pending" },
},
{
name: "not_pending: the wake was cancelled",
facts: { ...baseFacts, wakeStatus: "cancelled" },
expected: { kind: "not_pending" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideQueuedCommentWakeLookup(testCase.facts)).toEqual(testCase.expected);
});
}
});
describe("decideQueuedCommentReorder", () => {
const cases: Array<{
name: string;
facts: QueuedCommentReorderFacts;
expected: ReturnType<typeof decideQueuedCommentReorder>;
}> = [
{
name: "ok: the submitted order is a permutation of the current ids",
facts: { currentIds: ["a", "b", "c"], orderedIds: ["c", "a", "b"] },
expected: { kind: "ok" },
},
{
name: "mismatch: the submitted order carries a duplicate id",
facts: { currentIds: ["a", "b"], orderedIds: ["a", "a"] },
expected: { kind: "mismatch" },
},
{
name: "mismatch: the submitted order drops an id",
facts: { currentIds: ["a", "b", "c"], orderedIds: ["a", "b"] },
expected: { kind: "mismatch" },
},
{
name: "mismatch: the submitted order adds an id the queue does not have",
facts: { currentIds: ["a", "b"], orderedIds: ["a", "b", "c"] },
expected: { kind: "mismatch" },
},
{
name: "mismatch: the submitted order names an id the current queue does not have",
facts: { currentIds: ["a", "b"], orderedIds: ["a", "c"] },
expected: { kind: "mismatch" },
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideQueuedCommentReorder(testCase.facts)).toEqual(testCase.expected);
});
}
});
describe("decideQueuedCommentActorOwnsEntry", () => {
const cases: Array<{
name: string;
facts: QueuedCommentActorOwnershipFacts;
expected: boolean;
}> = [
{
name: "owns: a user actor authored the comment",
facts: { actorType: "user", actorId: "user-1", actorAgentId: null, authorAgentId: null, authorUserId: "user-1" },
expected: true,
},
{
name: "does not own: a user actor did not author the comment",
facts: { actorType: "user", actorId: "user-1", actorAgentId: null, authorAgentId: null, authorUserId: "user-2" },
expected: false,
},
{
name: "owns: an agent actor authored the comment as that agent",
facts: { actorType: "agent", actorId: "agent-1", actorAgentId: "agent-1", authorAgentId: "agent-1", authorUserId: null },
expected: true,
},
{
name: "does not own: an agent actor authored a different comment",
facts: { actorType: "agent", actorId: "agent-1", actorAgentId: "agent-1", authorAgentId: "agent-2", authorUserId: null },
expected: false,
},
{
name: "does not own: an agent actor with no resolved agent id",
facts: { actorType: "agent", actorId: "agent-1", actorAgentId: null, authorAgentId: null, authorUserId: null },
expected: false,
},
];
for (const testCase of cases) {
it(testCase.name, () => {
expect(decideQueuedCommentActorOwnsEntry(testCase.facts)).toBe(testCase.expected);
});
}
});
@@ -273,6 +273,98 @@ export function deriveImmediateRecoveryContextLabels(issueStatus: string): Immed
};
}
// Pure decision rules for the three queued-comment queue mutations (edit,
// reorder, discard): the queue-mutation target check every mutation runs
// first, the wake and queue-run status classification the initial lock
// step needs, the reorder set-equality check, the queue-entry permission
// fields the response carries, the actor-ownership check discard enforces,
// and the empty-versus-partial outcome a discard resolves to. As with every
// other decision in this file, the caller reads the database and packs the
// result into a facts object; this file only branches on that object.
export type QueuedCommentWakeLookupFacts = {
/** True when a wake row was found for the submitted queue id. */
wakePresent: boolean;
/** True when the wake's own payload still names this issue. */
wakeIssueIdMatches: boolean;
/** True when the wake's payload still carries one or more queued comment ids. */
hasQueuedCommentIds: boolean;
/** The wake row's own status. Meaningless when `wakePresent` is false. */
wakeStatus: string | null;
/** True when the wake row carries a linked heartbeat run id. */
wakeHasRunId: boolean;
};
export type QueuedCommentWakeLookupDecision =
| { kind: "not_pending" }
| { kind: "deferred" }
/** The caller must read the linked heartbeat run next and confirm it is still queued. */
| { kind: "check_queue_run" }
| { kind: "already_dispatching" };
/**
* Classifies a locked wake row into the queue state a mutation needs: still
* waiting behind an active run (`deferred`), queued behind a not-yet-running
* turn (needs a second read to confirm, `check_queue_run`), already being
* dispatched, or no longer a pending queue at all.
*/
export function decideQueuedCommentWakeLookup(facts: QueuedCommentWakeLookupFacts): QueuedCommentWakeLookupDecision {
if (!facts.wakePresent || !facts.wakeIssueIdMatches || !facts.hasQueuedCommentIds) {
return { kind: "not_pending" };
}
if (facts.wakeStatus === "deferred_issue_execution") return { kind: "deferred" };
if (facts.wakeStatus === "queued" && facts.wakeHasRunId) return { kind: "check_queue_run" };
if (
facts.wakeStatus === "claimed" ||
facts.wakeStatus === "running" ||
(facts.wakeHasRunId && (facts.wakeStatus === "succeeded" || facts.wakeStatus === "failed"))
) {
return { kind: "already_dispatching" };
}
return { kind: "not_pending" };
}
export type QueuedCommentReorderFacts = {
currentIds: string[];
orderedIds: string[];
};
export type QueuedCommentReorderDecision = { kind: "ok" } | { kind: "mismatch" };
/** Decides whether a submitted order is a permutation of the queue's current comment ids: no duplicates, no drops, no additions. */
export function decideQueuedCommentReorder(facts: QueuedCommentReorderFacts): QueuedCommentReorderDecision {
const orderedSet = new Set(facts.orderedIds);
if (
orderedSet.size !== facts.orderedIds.length ||
facts.orderedIds.length !== facts.currentIds.length ||
facts.currentIds.some((id) => !orderedSet.has(id))
) {
return { kind: "mismatch" };
}
return { kind: "ok" };
}
export type QueuedCommentActorOwnershipFacts = {
actorType: "agent" | "user";
actorId: string;
actorAgentId: string | null;
authorAgentId: string | null;
authorUserId: string | null;
};
/**
* Decides whether the actor discarding a queued message authored it. A user
* actor must be the board user who wrote the message. An agent actor must be
* the agent that wrote it, because an agent actor can discard its own queued
* message through the general comment-delete route.
*/
export function decideQueuedCommentActorOwnsEntry(facts: QueuedCommentActorOwnershipFacts): boolean {
if (facts.actorType === "agent") {
return facts.actorAgentId !== null && facts.authorAgentId === facts.actorAgentId;
}
return facts.authorUserId === facts.actorId;
}
/**
* Decides the release-recovery outcome once the deferred-wake queue is
* empty and no wake was promoted. The review-participant branch and the