Files
PaperClipAI/server/src/__tests__/issues-service.test.ts
T
DottaandPaperclip 5cf90417d6 fix(ui): confirm cancellation from monitor cards (#15735)
## Thinking Path

> - Paperclip helps people manage AI agents and their tasks.
> - A task monitor schedules a later agent check.
> - The monitor card shows when that check will run.
> - The card has no direct control to cancel the monitor.
> - This change adds an X button and a confirmation dialog.
> - The operator can stop the scheduled check from the card.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The monitor card on the task page.

**Current behavior**
The operator must open task properties to clear a monitor.

**Proposed behavior**
Select the X in the top right of the card. Select Keep monitor to leave
the schedule in place. Select Cancel monitor to remove the scheduled
check.

**Reason and benefit**
The operator can cancel the monitor where its schedule is shown.
Confirmation helps prevent an accidental cancellation.

**Breaking changes**
None. The change uses the existing task update API.

## What Changed

- Add an accessible X button at the top right of the monitor card.
- Confirm cancellation before the task update.
- Keep the dialog open with an error if the update fails.
- Preserve the other execution policy settings and refresh the task
after success.
- Reject stale policy writes under the task row lock with an optional
expectedExecutionPolicy snapshot.
- Document the snapshot field and add a database test for stale
cancellation.
- Restrict snapshot requests to policy-only edits before
execution-control actions.
- Add seven route tests for rejected mixed requests and absent policy
updates.
- Keep automatic retry waits separate from monitor cancellation.
- Add component and task page tests.

## Verification

- `pnpm check:token-gates` passed.
- Monitor banner and task page tests passed: 199 tests. A targeted
database test and seven route tests also passed.
- UI typecheck and production build passed.
- Open a task with a scheduled monitor. Select X, then Keep monitor. The
schedule must remain.
- Select X, then Cancel monitor. The card must disappear after the
update.
- Local full build reached the Rust runner build but could not complete
because Cargo is absent. CI must verify the full build.
- Full repository typecheck, build, test suites, and E2E checks passed
in CI for the final commit. Local full checks were limited by missing
Cargo and workspace memory.

## Risks

- Low risk. Cancellation uses the existing company access checks and
activity log.
- A cancelled monitor no longer wakes the agent. The dialog explains
this effect.

## Model Used

OpenAI Codex, GPT-6. The runtime does not expose a more specific model
ID or context window size. Used code editing, shell commands, and tests.

## 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>
2026-10-09 16:38:04 -05:00

7522 lines
229 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import {
activityLog,
agents,
companies,
companyMemberships,
createDb,
documentRevisions,
documents,
environments,
executionWorkspaces,
goals,
heartbeatRuns,
instanceSettings,
issueComments,
issueInboxArchives,
issueDocuments,
issueAccessGrants,
issuePlanDecompositions,
issueReadStates,
issueRelations,
issueThreadInteractions,
issueWorkProducts,
issues,
projectWorkspaces,
projectAccessMembers,
projects,
workspaceOperations,
toolApplications,
toolConnections,
toolOauthStates,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { instanceSettingsService } from "../services/instance-settings.ts";
import {
clampIssueListLimit,
deriveIssueCommentRunLogAttribution,
ISSUE_LIST_MAX_LIMIT,
issueService,
readIssueCommentRunLogText,
} from "../services/issues.ts";
import { getRunLogStore } from "../services/run-log-store.js";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
} from "../services/execution-workspace-policy.ts";
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("issue list limit helpers", () => {
it("clamps untrusted issue-list limits to the server maximum", () => {
expect(clampIssueListLimit(0)).toBe(1);
expect(clampIssueListLimit(25.9)).toBe(25);
expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT);
});
});
describeEmbeddedPostgres("issueService run attachment artifacts", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
afterAll(async () => {
await tempDb?.cleanup();
});
it("registers a run-produced attachment as an attachment-backed artifact work product", async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-attachment-artifact-");
const db = createDb(tempDb.connectionString);
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "ART",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "ArtifactAgent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Artifact registration",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
const attachment = await issueService(db).createAttachment({
issueId,
issueCommentId: null,
provider: "local_disk",
objectKey: "issues/artifact/screenshot.png",
contentType: "image/png",
byteSize: 128,
sha256: "a".repeat(64),
originalFilename: "screenshot.png",
createdByAgentId: agentId,
createdByRunId: runId,
});
const artifact = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.externalId, attachment.id))
.then((rows) => rows[0]);
expect(artifact).toMatchObject({
companyId,
issueId,
type: "artifact",
provider: "paperclip",
title: "screenshot.png",
createdByRunId: runId,
metadata: {
attachmentId: attachment.id,
contentType: "image/png",
byteSize: 128,
contentPath: `/api/attachments/${attachment.id}/content`,
openPath: `/api/attachments/${attachment.id}/content`,
downloadPath: `/api/attachments/${attachment.id}/content?download=1`,
originalFilename: "screenshot.png",
},
});
}, 20_000);
});
describe("readIssueCommentRunLogText", () => {
it("cancels timed-out storage reads so later listings can recover", async () => {
let active = 0;
const cleanups: Array<() => void> = [];
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation((_handle, options) =>
new Promise((_resolve, reject) => {
active += 1;
let settled = false;
const abort = () => {
if (settled) return;
settled = true;
active -= 1;
reject(new DOMException("Read aborted", "AbortError"));
};
cleanups.push(abort);
options?.signal?.addEventListener("abort", abort, { once: true });
}),
);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
const firstBatch = Array.from({ length: 8 }, () => readIssueCommentRunLogText(run));
try {
await vi.advanceTimersByTimeAsync(3_000);
expect(await Promise.all(firstBatch)).toEqual(Array(8).fill(""));
expect(active).toBe(0);
read.mockResolvedValueOnce({ content: "storage recovered" });
await expect(readIssueCommentRunLogText(run)).resolves.toBe("storage recovered");
expect(read).toHaveBeenCalledTimes(9);
} finally {
for (const cleanup of cleanups) cleanup();
await Promise.allSettled(firstBatch);
await vi.advanceTimersByTimeAsync(0);
vi.useRealTimers();
read.mockRestore();
}
});
it("keeps readable attribution evidence for concurrent listings", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation(async () => {
await gate;
return { content: "comment id: legacy-comment" };
});
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
const listings = Array.from({ length: 2 }, () =>
Promise.all(Array.from({ length: 8 }, () => readIssueCommentRunLogText(run))),
);
try {
release();
for (const listing of listings) {
expect(await listing).toEqual(Array(8).fill("comment id: legacy-comment"));
}
expect(read).toHaveBeenCalledTimes(16);
} finally {
release();
await Promise.allSettled(listings);
read.mockRestore();
}
});
it.each([null, 128])("keeps partial attribution evidence when storage fails with logBytes=%s", async (logBytes) => {
const read = vi.spyOn(getRunLogStore(), "read").mockRejectedValue(new Error("Storage gateway unavailable"));
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes };
try {
await expect(readIssueCommentRunLogText(run)).resolves.toBe("");
read.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 });
await expect(readIssueCommentRunLogText(run)).resolves.toBe("earlier evidence");
} finally {
read.mockRestore();
}
});
it("bounds reads that ignore cancellation and stops late pagination after the deadline", async () => {
let release!: (value: { content: string; nextOffset: number }) => void;
const stalled = new Promise<{ content: string; nextOffset: number }>((resolve) => {
release = resolve;
});
const read = vi.spyOn(getRunLogStore(), "read")
.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 })
.mockReturnValueOnce(stalled);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let result: string | undefined;
const pending = readIssueCommentRunLogText({
runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null,
}).then((value) => { result = value; });
try {
await vi.advanceTimersByTimeAsync(3_000);
expect(result).toBe("earlier evidence");
expect(read.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
release({ content: "too late", nextOffset: 24 });
await pending;
await vi.advanceTimersByTimeAsync(0);
expect(read).toHaveBeenCalledTimes(2);
expect(result).toBe("earlier evidence");
} finally {
release({ content: "", nextOffset: 0 });
await pending.catch(() => {});
vi.useRealTimers();
read.mockRestore();
}
});
it.each([null, 128, 0])("reads existing attribution markers with logBytes=%s", async (logBytes) => {
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const read = vi.spyOn(getRunLogStore(), "read")
.mockResolvedValueOnce({ content: "comment id: ", nextOffset: 12 })
.mockResolvedValueOnce({ content: commentId + "\n" });
try {
const logContent = await readIssueCommentRunLogText({
runId, logStore: "local_file", logRef: "test/run.ndjson", logBytes,
});
const derived = deriveIssueCommentRunLogAttribution(
[{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2020-01-01T00:00:01Z"),
}],
[{
runId,
agentId,
createdAt: new Date("2020-01-01T00:00:00Z"),
startedAt: new Date("2020-01-01T00:00:00Z"),
finishedAt: new Date("2020-01-01T00:00:02Z"),
logContent,
}],
);
if (logBytes === 0) {
expect(read).not.toHaveBeenCalled();
expect(derived.size).toBe(0);
} else {
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls[1]?.[1]?.offset).toBe(12);
expect(derived.get(commentId)).toEqual({
derivedAuthorAgentId: agentId,
derivedCreatedByRunId: runId,
derivedAuthorSource: "run_log_comment_post",
});
}
} finally {
read.mockRestore();
}
});
});
describe("deriveIssueCommentRunLogAttribution", () => {
it("recovers agent attribution from run logs that printed the posted comment id", () => {
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "user-1",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: `comment id: ${commentId}\n`,
},
],
);
expect(derived.get(commentId)).toEqual({
derivedAuthorAgentId: agentId,
derivedCreatedByRunId: runId,
derivedAuthorSource: "run_log_comment_post",
});
});
it("resolves directly from the comment's own run id without reading logs", () => {
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: runId,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "",
},
],
);
expect(derived.get(commentId)).toEqual({
derivedAuthorAgentId: agentId,
derivedCreatedByRunId: runId,
derivedAuthorSource: "run_id",
});
});
it("does NOT attribute on run-window overlap alone — timing is not a lossless signal (option A)", () => {
// A human board comment can land inside an agent's run window; since both are
// stored as `local-board`, a timing-only guess would mis-attribute it. So a
// single overlapping run with no run-id and no log marker stays unresolved.
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "posted results without echoing the comment id",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("does not guess when multiple agent runs overlap and no log proves the author", () => {
const commentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "no comment id here",
},
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:54:00.000Z"),
startedAt: new Date("2026-05-11T18:54:00.000Z"),
finishedAt: new Date("2026-05-11T18:56:00.000Z"),
logContent: "also nothing",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("does NOT attribute on same-agent run-window overlap alone (option A)", () => {
// Even when every overlapping run is the same agent, timing alone cannot
// prove the comment was the agent's vs a human board comment during the run.
const commentId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-06-29T17:41:59.916Z"),
},
],
[
{
runId: randomUUID(),
agentId,
createdAt: new Date("2026-06-29T17:41:26.116Z"),
startedAt: new Date("2026-06-29T17:41:26.116Z"),
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
logContent: "no comment id here",
},
{
runId: randomUUID(),
agentId,
createdAt: new Date("2026-06-29T17:40:09.531Z"),
startedAt: new Date("2026-06-29T17:40:09.531Z"),
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
logContent: "also nothing",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("never reattributes a comment that already has a stored agent author", () => {
const commentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: randomUUID(),
authorUserId: null,
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
});
async function ensureIssueRelationsTable(db: ReturnType<typeof createDb>) {
await db.execute(sql.raw(`
CREATE TABLE IF NOT EXISTS "issue_relations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"related_issue_id" uuid NOT NULL,
"type" text NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
`));
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
it("rejects stale monitor cancellation under the task lock and preserves saved policy settings", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Monitor cancellation", issuePrefix: "MCL", requireBoardApprovalForNewAgents: false });
const oldPolicy = {
mode: "normal" as const, commentRequired: true, stages: [],
monitor: { nextCheckAt: "2026-10-10T12:00:00.000Z", notes: "Check status", scheduledBy: "board" as const },
};
const savedPolicy = {
...oldPolicy,
stages: [{ type: "review" as const, approvalsNeeded: 1 as const, participants: [{ type: "user" as const, userId: "reviewer" }] }],
maxReviewRounds: 4,
authorizationPolicy: { assignmentPolicy: { mode: "protected" as const } },
};
await db.insert(issues).values({ id: issueId, companyId, title: "Monitored task", executionPolicy: oldPolicy });
await svc.update(issueId, { executionPolicy: savedPolicy });
const { monitor: _oldMonitor, ...staleCancellation } = oldPolicy;
await expect(svc.update(issueId, { executionPolicy: staleCancellation, expectedExecutionPolicy: oldPolicy }))
.rejects.toMatchObject({ status: 409 });
expect((await svc.getById(issueId))?.executionPolicy).toEqual(savedPolicy);
const { monitor: _savedMonitor, ...cancellation } = savedPolicy;
await svc.update(issueId, { executionPolicy: cancellation, expectedExecutionPolicy: savedPolicy });
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
await expect(svc.update(issueId, { executionPolicy: null, expectedExecutionPolicy: null }))
.rejects.toMatchObject({ status: 409 });
expect((await svc.getById(issueId))?.executionPolicy).toEqual(cancellation);
});
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueThreadInteractions);
await db.delete(issueRelations);
await db.delete(issueDocuments);
await db.delete(issueInboxArchives);
await db.delete(issueReadStates);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(documents);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedAssignableAgentCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
it("does not treat passive issue activity as touching it, but includes real user mutations", async () => {
const companyId = await seedAssignableAgentCompany();
const issueId = randomUUID();
const userId = "board-user";
await db.insert(issues).values({
id: issueId,
companyId,
title: "Issue viewed without participation",
status: "todo",
priority: "medium",
});
await svc.markRead(companyId, issueId, userId);
await db.insert(activityLog).values([
{
companyId,
actorType: "user",
actorId: userId,
action: "issue.read_marked",
entityType: "issue",
entityId: issueId,
},
{
companyId,
actorType: "user",
actorId: userId,
action: "issue.file_resource_content_read",
entityType: "issue",
entityId: issueId,
},
{
companyId,
actorType: "user",
actorId: userId,
action: "issue.file_resource_download_denied",
entityType: "issue",
entityId: issueId,
},
{
companyId,
actorType: "user",
actorId: userId,
action: "issue.tree_control_previewed",
entityType: "issue",
entityId: issueId,
},
]);
await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([]);
await db.insert(activityLog).values({
companyId,
actorType: "user",
actorId: userId,
action: "issue.comment_cancelled",
entityType: "issue",
entityId: issueId,
});
await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([
expect.objectContaining({ id: issueId }),
]);
});
function agentRow(companyId: string, input: {
id: string;
name: string;
status?: string;
reportsTo?: string | null;
}) {
return {
id: input.id,
companyId,
name: input.name,
role: "engineer",
status: input.status ?? "active",
reportsTo: input.reportsTo ?? null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
};
}
it("rejects direct terminated assignees with structured conflict details", async () => {
const companyId = await seedAssignableAgentCompany();
const terminatedAgentId = randomUUID();
await db.insert(agents).values(agentRow(companyId, {
id: terminatedAgentId,
name: "TerminatedCoder",
status: "terminated",
}));
await expect(svc.create(companyId, {
title: "Do not assign this",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: terminatedAgentId,
})).rejects.toMatchObject({
status: 409,
details: {
code: "agent_not_assignable",
reason: "assignee_terminated",
assigneeAgentId: terminatedAgentId,
},
});
});
it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => {
const companyId = await seedAssignableAgentCompany();
const activeAgentId = randomUUID();
const terminatedManagerId = randomUUID();
const blockedAgentId = randomUUID();
await db.insert(agents).values([
agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }),
agentRow(companyId, {
id: terminatedManagerId,
name: "TerminatedManager",
status: "terminated",
}),
agentRow(companyId, {
id: blockedAgentId,
name: "BlockedCoder",
reportsTo: terminatedManagerId,
}),
]);
const issue = await svc.create(companyId, {
title: "Keep current assignment",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: activeAgentId,
});
await expect(svc.update(issue.id, {
assigneeAgentId: blockedAgentId,
})).rejects.toMatchObject({
status: 409,
details: {
code: "agent_not_assignable",
reason: "ancestor_terminated",
assigneeAgentId: blockedAgentId,
invalidAncestorAgentId: terminatedManagerId,
},
});
const persisted = await db
.select({ assigneeAgentId: issues.assigneeAgentId })
.from(issues)
.where(eq(issues.id, issue.id))
.then((rows) => rows[0] ?? null);
expect(persisted?.assigneeAgentId).toBe(activeAgentId);
});
it("rejects checkout by a terminated agent before assigning the issue", async () => {
const companyId = await seedAssignableAgentCompany();
const terminatedAgentId = randomUUID();
await db.insert(agents).values(agentRow(companyId, {
id: terminatedAgentId,
name: "TerminatedCheckoutCoder",
status: "terminated",
}));
const issue = await svc.create(companyId, {
title: "Checkout must stay unassigned",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
});
await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID()))
.rejects.toMatchObject({
status: 409,
details: {
code: "agent_not_assignable",
reason: "assignee_terminated",
assigneeAgentId: terminatedAgentId,
},
});
const persisted = await db
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
.from(issues)
.where(eq(issues.id, issue.id))
.then((rows) => rows[0] ?? null);
expect(persisted).toMatchObject({
assigneeAgentId: null,
status: "todo",
});
});
it("expires a pending connection and its OAuth state when reassigned to a user without a status change", async () => {
const companyId = await seedAssignableAgentCompany();
const agentId = randomUUID();
const userId = randomUUID();
await db.insert(agents).values(agentRow(companyId, { id: agentId, name: "ConnectionRequester" }));
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member",
});
const issue = await svc.create(companyId, {
title: "Waiting for a connection", status: "in_review", priority: "medium", assigneeAgentId: agentId,
});
const [application] = await db.insert(toolApplications).values({
companyId, name: "Connection app", type: "mcp",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId, applicationId: application!.id, name: "Notion", uid: randomUUID(),
transport: "mcp_remote", authKind: "oauth",
}).returning();
const [interaction] = await db.insert(issueThreadInteractions).values({
companyId, issueId: issue.id, kind: "connection_intent", status: "pending",
createdByAgentId: agentId, addresseeUserId: "local-board",
payload: {
version: 1, serviceSlug: "notion", serviceName: "Notion",
requestingAgentId: agentId, requestingAgentName: "ConnectionRequester", phase: "authorizing",
},
}).returning();
const oauthState = randomUUID();
await db.insert(toolOauthStates).values({
state: oauthState, companyId, connectionId: connection!.id, issueId: issue.id,
interactionId: interaction!.id, codeVerifier: "test-verifier",
createdByActorType: "user", createdByActorId: "local-board",
expiresAt: new Date(Date.now() + 60_000),
});
const updated = await svc.update(issue.id, {
assigneeAgentId: null, assigneeUserId: userId, actorUserId: "local-board",
});
expect(updated).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: userId });
await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id)))
.resolves.toEqual([expect.objectContaining({
status: "expired",
result: { version: 1, outcome: "expired", reason: "The task assignment changed" },
resolvedAt: expect.any(Date),
})]);
await expect(db.select().from(toolOauthStates).where(eq(toolOauthStates.state, oauthState))).resolves.toEqual([]);
});
it("expires pending thread interactions on any service-level terminal transition", async () => {
const companyId = await seedAssignableAgentCompany();
const issue = await svc.create(companyId, {
title: "Close me with a pending card",
description: null,
status: "todo",
priority: "medium",
});
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId: issue.id,
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Pick one",
selectionMode: "single",
options: [{ id: "a", label: "A" }],
}],
} as never,
});
// Direct service callers (tree control, recovery, pipelines, status cards)
// never pass through the HTTP routes, so the expiry must fire here.
const updated = await svc.update(issue.id, { status: "cancelled", actorUserId: "local-board" });
expect(updated?.status).toBe("cancelled");
const interaction = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0] ?? null);
expect(interaction).toMatchObject({
status: "expired",
resolvedByUserId: "local-board",
});
expect(interaction?.result).toMatchObject({ version: 1, outcome: "issue_closed" });
expect(interaction?.resolvedAt).not.toBeNull();
const logged = await db
.select()
.from(activityLog)
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
expect(logged).toHaveLength(1);
// details.source is dot-separated and trips the JWT-shaped redaction
// heuristic in sanitizeRecord, so assert the identifying fields instead.
expect(logged[0]?.details).toMatchObject({
interactionId,
interactionKind: "ask_user_questions",
interactionStatus: "expired",
});
});
it("expires superseded interactions when human comments are added through the service", async () => {
const companyId = await seedAssignableAgentCompany();
const issue = await svc.create(companyId, {
title: "Answer with a comment",
description: null,
status: "in_review",
priority: "medium",
});
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId: issue.id,
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [{
id: "scope",
prompt: "Pick one",
selectionMode: "single",
options: [{ id: "a", label: "A" }],
}],
} as never,
});
const comment = await svc.addComment(issue.id, "Use option A", { userId: "local-board" });
const interaction = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0] ?? null);
expect(interaction).toMatchObject({
status: "expired",
resolvedByUserId: "local-board",
});
expect(interaction?.result).toMatchObject({
expirationReason: "superseded_by_comment",
commentId: comment.id,
});
const logged = await db
.select()
.from(activityLog)
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
expect(logged).toHaveLength(1);
expect(logged[0]?.details).toMatchObject({
interactionId,
interactionKind: "ask_user_questions",
interactionStatus: "expired",
});
});
it("keeps interactions pending when the board concierge adds a comment", async () => {
const companyId = await seedAssignableAgentCompany();
const issue = await svc.create(companyId, {
title: "Concierge reply",
description: null,
status: "in_review",
priority: "medium",
});
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId: issue.id,
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [{
id: "scope",
prompt: "Pick one",
selectionMode: "single",
options: [{ id: "a", label: "A" }],
}],
} as never,
});
await svc.addComment(issue.id, "Automated concierge reply", {
userId: "board-concierge",
});
const interaction = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0] ?? null);
expect(interaction).toMatchObject({
status: "pending",
resolvedByUserId: null,
result: null,
});
const logged = await db
.select()
.from(activityLog)
.where(eq(activityLog.action, "issue.thread_interaction_expired"));
expect(logged).toHaveLength(0);
});
it("rejects moving an existing terminated assignment into progress without clearing it", async () => {
const companyId = await seedAssignableAgentCompany();
const assigneeAgentId = randomUUID();
await db.insert(agents).values(agentRow(companyId, {
id: assigneeAgentId,
name: "SoonTerminatedCoder",
}));
const issue = await svc.create(companyId, {
title: "Do not restart after termination",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId,
});
await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId));
await expect(svc.update(issue.id, {
status: "in_progress",
})).rejects.toMatchObject({
status: 409,
details: {
code: "agent_not_assignable",
reason: "assignee_terminated",
assigneeAgentId,
},
});
const persisted = await db
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
.from(issues)
.where(eq(issues.id, issue.id))
.then((rows) => rows[0] ?? null);
expect(persisted).toMatchObject({
assigneeAgentId,
status: "todo",
});
});
it("resolves only structured same-company agent mentions", async () => {
const companyId = await seedAssignableAgentCompany();
const otherCompanyId = await seedAssignableAgentCompany();
const localAgentId = randomUUID();
const foreignAgentId = randomUUID();
await db.insert(agents).values([
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
agentRow(otherCompanyId, { id: foreignAgentId, name: "ForeignAgent" }),
]);
const mentions = await svc.findMentionedAgents(
companyId,
[
`hello [@LocalAgent](${buildAgentMentionHref(localAgentId)})`,
`and [@ForeignAgent](${buildAgentMentionHref(foreignAgentId)})`,
].join(" "),
);
expect(mentions).toEqual([localAgentId]);
});
it("does not wake agents from raw @name text without a structured mention", async () => {
const companyId = await seedAssignableAgentCompany();
const localAgentId = randomUUID();
await db.insert(agents).values([
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
]);
await expect(svc.findMentionedAgents(companyId, "@LocalAgent please inspect this"))
.resolves.toEqual([]);
});
it("returns issues an agent participated in across the supported signals", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "OtherAgent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
const assignedIssueId = randomUUID();
const createdIssueId = randomUUID();
const commentedIssueId = randomUUID();
const activityIssueId = randomUUID();
const excludedIssueId = randomUUID();
await db.insert(issues).values([
{
id: assignedIssueId,
companyId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
createdByAgentId: otherAgentId,
},
{
id: createdIssueId,
companyId,
title: "Created issue",
status: "todo",
priority: "medium",
createdByAgentId: agentId,
},
{
id: commentedIssueId,
companyId,
title: "Commented issue",
status: "todo",
priority: "medium",
createdByAgentId: otherAgentId,
},
{
id: activityIssueId,
companyId,
title: "Activity issue",
status: "todo",
priority: "medium",
createdByAgentId: otherAgentId,
},
{
id: excludedIssueId,
companyId,
title: "Excluded issue",
status: "todo",
priority: "medium",
createdByAgentId: otherAgentId,
assigneeAgentId: otherAgentId,
},
]);
await db.insert(issueComments).values({
companyId,
issueId: commentedIssueId,
authorAgentId: agentId,
body: "Investigating this issue.",
});
await db.insert(activityLog).values({
companyId,
actorType: "agent",
actorId: agentId,
action: "issue.updated",
entityType: "issue",
entityId: activityIssueId,
agentId,
details: { changed: true },
});
const result = await svc.list(companyId, { participantAgentId: agentId });
const resultIds = new Set(result.map((issue) => issue.id));
expect(resultIds).toEqual(new Set([
assignedIssueId,
createdIssueId,
commentedIssueId,
activityIssueId,
]));
expect(resultIds.has(excludedIssueId)).toBe(false);
});
it("combines participation filtering with search", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const matchedIssueId = randomUUID();
const otherIssueId = randomUUID();
await db.insert(issues).values([
{
id: matchedIssueId,
companyId,
title: "Invoice reconciliation",
status: "todo",
priority: "medium",
createdByAgentId: agentId,
},
{
id: otherIssueId,
companyId,
title: "Weekly planning",
status: "todo",
priority: "medium",
createdByAgentId: agentId,
},
]);
const result = await svc.list(companyId, {
participantAgentId: agentId,
q: "invoice",
});
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("treats assigneeAgentId='null' as an explicit unassigned filter", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const assignedIssueId = randomUUID();
const unassignedIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "Assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: assignedIssueId,
companyId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId,
},
{
id: unassignedIssueId,
companyId,
title: "Unassigned issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
},
]);
const result = await svc.list(companyId, { assigneeAgentId: "null" });
expect(result.map((issue) => issue.id)).toEqual([unassignedIssueId]);
});
it("keeps UUID assignee filtering behavior unchanged", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const otherAgentId = randomUUID();
const assignedIssueId = randomUUID();
const otherIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: assigneeAgentId,
companyId,
name: "Assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "Other",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(issues).values([
{
id: assignedIssueId,
companyId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId,
},
{
id: otherIssueId,
companyId,
title: "Other issue",
status: "todo",
priority: "medium",
assigneeAgentId: otherAgentId,
},
]);
const result = await svc.list(companyId, { assigneeAgentId });
expect(result.map((issue) => issue.id)).toEqual([assignedIssueId]);
});
it("rejects malformed assigneeAgentId filter values", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: randomUUID(),
companyId,
title: "Any issue",
status: "todo",
priority: "medium",
});
await expect(
svc.list(companyId, { assigneeAgentId: "not-a-uuid" }),
).rejects.toThrow(/assigneeAgentId/i);
});
it("counts only unassigned issues for assigneeAgentId='null'", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const assignedIssueId = randomUUID();
const unassignedIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "Assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: assignedIssueId,
companyId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId,
},
{
id: unassignedIssueId,
companyId,
title: "Unassigned issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
},
]);
await expect(svc.count(companyId, { assigneeAgentId: "null" })).resolves.toBe(1);
});
it("counts UUID-assigned issues with assigneeAgentId", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const assignedIssueId = randomUUID();
const otherIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "Assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: assignedIssueId,
companyId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId,
},
{
id: otherIssueId,
companyId,
title: "Other issue",
status: "todo",
priority: "medium",
},
]);
await expect(svc.count(companyId, { assigneeAgentId })).resolves.toBe(1);
});
it("rejects malformed assigneeAgentId filter values in count", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await expect(
svc.count(companyId, { assigneeAgentId: "not-a-uuid" }),
).rejects.toThrow(/assigneeAgentId/i);
});
it("applies result limits to issue search", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const exactIdentifierId = randomUUID();
const titleMatchId = randomUUID();
const descriptionMatchId = randomUUID();
await db.insert(issues).values([
{
id: exactIdentifierId,
companyId,
issueNumber: 42,
identifier: "PAP-42",
title: "Completely unrelated",
status: "todo",
priority: "medium",
},
{
id: titleMatchId,
companyId,
title: "Search ranking issue",
status: "todo",
priority: "medium",
},
{
id: descriptionMatchId,
companyId,
title: "Another item",
description: "Contains the search keyword",
status: "todo",
priority: "medium",
},
]);
const result = await svc.list(companyId, {
q: "search",
limit: 2,
});
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
});
it("filters issues by whether they have a plan document", async () => {
const companyId = randomUUID();
const withPlanId = randomUUID();
const withoutPlanId = randomUUID();
const otherDocumentId = randomUUID();
const planDocumentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: withPlanId,
companyId,
title: "Issue with plan",
status: "todo",
priority: "medium",
},
{
id: withoutPlanId,
companyId,
title: "Issue without plan",
status: "todo",
priority: "medium",
},
]);
await db.insert(documents).values([
{
id: planDocumentId,
companyId,
title: null,
format: "markdown",
latestBody: "# Plan",
},
{
id: otherDocumentId,
companyId,
title: "Notes",
format: "markdown",
latestBody: "# Notes",
},
]);
await db.insert(issueDocuments).values([
{
companyId,
issueId: withPlanId,
documentId: planDocumentId,
key: "plan",
},
{
companyId,
issueId: withoutPlanId,
documentId: otherDocumentId,
key: "notes",
},
]);
const withPlan = await svc.list(companyId, { hasPlanDocument: true });
const withoutPlan = await svc.list(companyId, { hasPlanDocument: false });
expect(withPlan.map((issue) => issue.id)).toEqual([withPlanId]);
expect(withoutPlan.map((issue) => issue.id)).toEqual([withoutPlanId]);
});
it("can page issues by most recently updated before priority", async () => {
const companyId = randomUUID();
const oldCriticalIssueId = randomUUID();
const recentMediumIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: oldCriticalIssueId,
companyId,
title: "Old critical issue",
status: "todo",
priority: "critical",
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
},
{
id: recentMediumIssueId,
companyId,
title: "Recent medium issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
},
]);
const result = await svc.list(companyId, {
limit: 1,
sortField: "updated",
sortDir: "desc",
});
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
});
it("ranks direct description matches ahead of comment-only matches", async () => {
const companyId = randomUUID();
const commentMatchId = randomUUID();
const descriptionMatchId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: commentMatchId,
companyId,
title: "Comment match",
status: "todo",
priority: "medium",
},
{
id: descriptionMatchId,
companyId,
title: "Description match",
description: "Contains pull/3303 in the description",
status: "todo",
priority: "medium",
},
]);
await db.insert(issueComments).values({
companyId,
issueId: commentMatchId,
body: "Reference: https://github.com/paperclipai/paperclip/pull/3303",
});
const result = await svc.list(companyId, {
q: "pull/3303",
limit: 2,
includeRoutineExecutions: true,
});
expect(result.map((issue) => issue.id)).toEqual([descriptionMatchId, commentMatchId]);
});
it("filters issue lists to the full descendant tree for a root issue", async () => {
const companyId = randomUUID();
const rootId = randomUUID();
const childId = randomUUID();
const grandchildId = randomUUID();
const siblingId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: rootId,
companyId,
title: "Root",
status: "todo",
priority: "medium",
},
{
id: childId,
companyId,
parentId: rootId,
title: "Child",
status: "todo",
priority: "medium",
},
{
id: grandchildId,
companyId,
parentId: childId,
title: "Grandchild",
status: "todo",
priority: "medium",
},
{
id: siblingId,
companyId,
title: "Sibling",
status: "todo",
priority: "medium",
},
]);
const result = await svc.list(companyId, { descendantOf: rootId });
expect(new Set(result.map((issue) => issue.id))).toEqual(new Set([childId, grandchildId]));
});
it("combines descendant filtering with search", async () => {
const companyId = randomUUID();
const rootId = randomUUID();
const childId = randomUUID();
const grandchildId = randomUUID();
const outsideMatchId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: rootId,
companyId,
title: "Root",
status: "todo",
priority: "medium",
},
{
id: childId,
companyId,
parentId: rootId,
title: "Relevant parent",
status: "todo",
priority: "medium",
},
{
id: grandchildId,
companyId,
parentId: childId,
title: "Needle grandchild",
status: "todo",
priority: "medium",
},
{
id: outsideMatchId,
companyId,
title: "Needle outside",
status: "todo",
priority: "medium",
},
]);
const result = await svc.list(companyId, { descendantOf: rootId, q: "needle" });
expect(result.map((issue) => issue.id)).toEqual([grandchildId]);
});
it("accepts issue identifiers with alphanumeric prefixes through getById", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PC1A2",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1064,
identifier: "PC1A2-1064",
title: "Feedback votes error",
status: "todo",
priority: "medium",
createdByUserId: "user-1",
});
const issue = await svc.getById("pc1a2-1064");
expect(issue).toEqual(
expect.objectContaining({
id: issueId,
identifier: "PC1A2-1064",
}),
);
});
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
});
it("filters issues by execution workspace id", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const targetWorkspaceId = randomUUID();
const otherWorkspaceId = randomUUID();
const linkedIssueId = randomUUID();
const otherLinkedIssueId = randomUUID();
const unlinkedIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(executionWorkspaces).values([
{
id: targetWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Target workspace",
status: "active",
providerType: "local_fs",
},
{
id: otherWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Other workspace",
status: "active",
providerType: "local_fs",
},
]);
await db.insert(issues).values([
{
id: linkedIssueId,
companyId,
projectId,
title: "Linked issue",
status: "todo",
priority: "medium",
executionWorkspaceId: targetWorkspaceId,
},
{
id: otherLinkedIssueId,
companyId,
projectId,
title: "Other linked issue",
status: "todo",
priority: "medium",
executionWorkspaceId: otherWorkspaceId,
},
{
id: unlinkedIssueId,
companyId,
projectId,
title: "Unlinked issue",
status: "todo",
priority: "medium",
},
]);
const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId });
expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]);
});
it("filters issues by generic workspace id across execution and project workspace links", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const executionLinkedIssueId = randomUUID();
const projectLinkedIssueId = randomUUID();
const otherIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Feature workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: false,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values([
{
id: executionLinkedIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Execution linked issue",
status: "done",
priority: "medium",
executionWorkspaceId,
},
{
id: projectLinkedIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Project linked issue",
status: "todo",
priority: "medium",
},
{
id: otherIssueId,
companyId,
projectId,
title: "Other issue",
status: "todo",
priority: "medium",
},
]);
const executionResult = await svc.list(companyId, { workspaceId: executionWorkspaceId });
const projectResult = await svc.list(companyId, { workspaceId: projectWorkspaceId });
expect(executionResult.map((issue) => issue.id)).toEqual([executionLinkedIssueId]);
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
});
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const normalIssueId = randomUUID();
const pluginVisibleIssueId = randomUUID();
const operationIssueId = randomUUID();
const typedOperationIssueId = randomUUID();
const legacyContentMachineOperationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Plugin Runner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Plugin operations",
status: "in_progress",
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: pluginVisibleIssueId,
companyId,
title: "Plugin-visible issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:feature",
},
{
id: operationIssueId,
companyId,
projectId,
title: "Plugin operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:operation",
originId: "mission-alpha:operation-1",
},
{
id: typedOperationIssueId,
companyId,
projectId,
title: "Typed plugin operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:operation:evaluation",
originId: "mission-alpha:operation-2",
},
{
id: legacyContentMachineOperationIssueId,
companyId,
projectId,
title: "Legacy Content Machine operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclipai.content-machine:evaluation",
originId: "content-machine-operation-1",
},
]);
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
expect(defaultIssueIds).toContain(normalIssueId);
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
expect(defaultIssueIds).not.toContain(operationIssueId);
expect(defaultIssueIds).not.toContain(typedOperationIssueId);
expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
const inboxIssueIds = (await svc.list(companyId, {
assigneeAgentId: agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
})).map((issue) => issue.id);
expect(inboxIssueIds).toContain(normalIssueId);
expect(inboxIssueIds).not.toContain(operationIssueId);
expect(inboxIssueIds).not.toContain(typedOperationIssueId);
expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" }))
.resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ id: operationIssueId }),
expect.objectContaining({ id: typedOperationIssueId }),
]));
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
expect(projectIssueIds).toContain(operationIssueId);
expect(projectIssueIds).toContain(typedOperationIssueId);
expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
expect(advancedIssueIds).toContain(operationIssueId);
expect(advancedIssueIds).toContain(typedOperationIssueId);
expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
});
it("excludes plugin operation issues from unread inbox counts", async () => {
const companyId = randomUUID();
const userId = "board-user";
const otherUserId = "other-user";
const normalIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
},
{
id: operationIssueId,
companyId,
title: "Plugin operation touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
originKind: "plugin:paperclip.missions:operation",
},
]);
await db.insert(issueComments).values([
{
companyId,
issueId: normalIssueId,
authorUserId: otherUserId,
body: "Unread normal update.",
},
{
companyId,
issueId: operationIssueId,
authorUserId: otherUserId,
body: "Unread operation update.",
},
]);
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
await expect(svc.countUnreadTouchedByUser(companyId, userId, ["todo", "in_progress"])).resolves.toBe(1);
});
it("accepts array-form status filters in list and count", async () => {
const companyId = randomUUID();
const todoIssueId = randomUUID();
const inProgressIssueId = randomUUID();
const doneIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: todoIssueId,
companyId,
title: "Todo issue",
status: "todo",
priority: "medium",
},
{
id: inProgressIssueId,
companyId,
title: "In-progress issue",
status: "in_progress",
priority: "medium",
},
{
id: doneIssueId,
companyId,
title: "Done issue",
status: "done",
priority: "medium",
},
]);
const resultIds = (await svc.list(companyId, { status: ["todo", "in_progress"] }))
.map((issue) => issue.id);
expect(resultIds).toEqual(expect.arrayContaining([todoIssueId, inProgressIssueId]));
expect(resultIds).not.toContain(doneIssueId);
await expect(svc.count(companyId, { status: ["todo", "in_progress"] })).resolves.toBe(2);
});
it("hides archived inbox issues until new external activity arrives", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const visibleIssueId = randomUUID();
const archivedIssueId = randomUUID();
const resurfacedIssueId = randomUUID();
await db.insert(issues).values([
{
id: visibleIssueId,
companyId,
title: "Visible issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: archivedIssueId,
companyId,
title: "Archived issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
},
{
id: resurfacedIssueId,
companyId,
title: "Resurfaced issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
},
]);
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
await db.insert(issueComments).values({
companyId,
issueId: resurfacedIssueId,
authorUserId: otherUserId,
body: "This should bring the issue back into Mine.",
createdAt: new Date("2026-03-26T13:30:00.000Z"),
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
});
const archivedFiltered = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
resurfacedIssueId,
visibleIssueId,
]);
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
const afterUnarchive = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
visibleIssueId,
archivedIssueId,
resurfacedIssueId,
]));
});
it("resurfaces archived issues only for user-attention events", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Worker",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
const issueIds = {
updatedAt: randomUUID(),
agentComment: randomUUID(),
derivedAgentComment: randomUUID(),
systemComment: randomUUID(),
suggestTasks: randomUUID(),
askQuestions: randomUUID(),
requestConfirmation: randomUUID(),
inReview: randomUUID(),
blocked: randomUUID(),
done: randomUUID(),
humanComment: randomUUID(),
mention: randomUUID(),
unarchived: randomUUID(),
};
await db.insert(issues).values(Object.entries(issueIds).map(([title, id]) => ({
id,
companyId,
title,
status: "todo",
priority: "medium" as const,
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
})));
const archivedAt = new Date("2026-03-26T12:00:00.000Z");
for (const issueId of Object.values(issueIds)) {
await svc.archiveInbox(companyId, issueId, userId, archivedAt);
}
const listVisibleIds = async () => new Set((await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
})).map((issue) => issue.id));
await expect(listVisibleIds()).resolves.toEqual(new Set());
await db
.update(issues)
.set({ status: "in_progress", updatedAt: new Date("2026-03-26T13:00:00.000Z") })
.where(eq(issues.id, issueIds.updatedAt));
await db.insert(issueComments).values([
{
companyId,
issueId: issueIds.agentComment,
authorAgentId: agentId,
body: "Agent progress update",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.derivedAgentComment,
authorUserId: "local-board",
derivedAuthorAgentId: agentId,
derivedAuthorSource: "run_log_comment_post",
body: "Legacy agent-attributed progress update",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.systemComment,
body: "System lifecycle update",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.humanComment,
authorUserId: otherUserId,
body: "A human needs your attention",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.mention,
authorAgentId: agentId,
body: "Please review this, [Viewer](user://user-1)",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
]);
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds.suggestTasks,
kind: "suggest_tasks",
payload: { version: 1, tasks: [{ clientKey: "follow-up", title: "Follow up" }] },
createdByAgentId: agentId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
});
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds.askQuestions,
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [] }],
},
createdByAgentId: agentId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
});
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds.requestConfirmation,
kind: "request_confirmation",
payload: { version: 1, prompt: "Proceed?" },
createdByAgentId: agentId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
});
await db.insert(activityLog).values([
[issueIds.inReview, "in_review"],
[issueIds.blocked, "blocked"],
[issueIds.done, "done"],
].map(([issueId, status]) => ({
companyId,
actorType: "agent",
actorId: agentId,
agentId,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
details: { status, _previous: { status: "in_progress" } },
createdAt: new Date("2026-03-26T13:00:00.000Z"),
})));
await svc.unarchiveInbox(companyId, issueIds.unarchived, userId);
const expectedVisible = new Set([
issueIds.suggestTasks,
issueIds.askQuestions,
issueIds.requestConfirmation,
issueIds.inReview,
issueIds.blocked,
issueIds.done,
issueIds.humanComment,
issueIds.mention,
issueIds.unarchived,
]);
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
await svc.archiveInbox(
companyId,
issueIds.humanComment,
userId,
new Date("2026-03-26T14:00:00.000Z"),
);
expectedVisible.delete(issueIds.humanComment);
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
});
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
const companyId = randomUUID();
const olderIssueId = randomUUID();
const commentIssueId = randomUUID();
const activityIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: olderIssueId,
companyId,
title: "Older issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: commentIssueId,
companyId,
title: "Comment activity issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: activityIssueId,
companyId,
title: "Logged activity issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
]);
await db.insert(issueComments).values({
companyId,
issueId: commentIssueId,
body: "New comment without touching issue.updatedAt",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
});
await db.insert(activityLog).values([
{
companyId,
actorType: "system",
actorId: "system",
action: "issue.document_updated",
entityType: "issue",
entityId: activityIssueId,
createdAt: new Date("2026-03-26T12:00:00.000Z"),
},
{
companyId,
actorType: "user",
actorId: "user-1",
action: "issue.read_marked",
entityType: "issue",
entityId: olderIssueId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
},
]);
const result = await svc.list(companyId, {});
expect(result.map((issue) => issue.id)).toEqual([
activityIssueId,
commentIssueId,
olderIssueId,
]);
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T12:00:00.000Z",
);
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T11:00:00.000Z",
);
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T10:00:00.000Z",
);
});
it("paginates earlier comments in descending order from an anchor comment", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const firstCommentId = randomUUID();
const anchorCommentId = randomUUID();
const latestCommentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Paged comments issue",
status: "todo",
priority: "medium",
});
await db.insert(issueComments).values([
{
id: firstCommentId,
companyId,
issueId,
body: "First comment",
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: anchorCommentId,
companyId,
issueId,
body: "Anchor comment",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
},
{
id: latestCommentId,
companyId,
issueId,
body: "Latest comment",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
},
]);
const comments = await svc.listComments(issueId, {
afterCommentId: anchorCommentId,
order: "desc",
limit: 50,
});
expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]);
});
it("paginates later comments in ascending order from an anchor comment", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const firstCommentId = randomUUID();
const anchorCommentId = randomUUID();
const latestCommentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Paged comments issue",
status: "todo",
priority: "medium",
});
await db.insert(issueComments).values([
{
id: firstCommentId,
companyId,
issueId,
body: "First comment",
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: anchorCommentId,
companyId,
issueId,
body: "Anchor comment",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
},
{
id: latestCommentId,
companyId,
issueId,
body: "Latest comment",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
},
]);
const comments = await svc.listComments(issueId, {
afterCommentId: anchorCommentId,
order: "asc",
limit: 50,
});
expect(comments.map((comment) => comment.id)).toEqual([latestCommentId]);
});
it("returns no comments for an anchor cursor that is not a UUID", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Malformed cursor issue",
status: "todo",
priority: "medium",
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
body: "Only comment",
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
});
const comments = await svc.listComments(issueId, {
afterCommentId: commentId.slice(0, 8),
order: "asc",
limit: 50,
});
expect(comments).toEqual([]);
});
it("lists user comments when derived run attribution scans a timestamp window", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Comments issue",
status: "todo",
priority: "medium",
});
await db.insert(heartbeatRuns).values({
id: randomUUID(),
companyId,
agentId,
contextSnapshot: { issueId },
createdAt: new Date("2026-05-12T22:58:00.000Z"),
startedAt: new Date("2026-05-12T22:58:00.000Z"),
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorUserId: "user-1",
body: "Comment should be visible",
createdAt: new Date("2026-05-12T23:00:00.000Z"),
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
});
const comments = await svc.listComments(issueId, {
order: "desc",
limit: 50,
});
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
expect(comments[0]?.body).toBe("Comment should be visible");
});
it("lists user comments when a candidate attribution run log is missing", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Comments issue with missing run log",
status: "todo",
priority: "medium",
});
await db.insert(heartbeatRuns).values({
id: randomUUID(),
companyId,
agentId,
contextSnapshot: { issueId },
createdAt: new Date("2026-05-12T22:58:00.000Z"),
startedAt: new Date("2026-05-12T22:58:00.000Z"),
finishedAt: new Date("2026-05-12T23:14:00.000Z"),
logStore: "local_file",
logRef: "missing/run-log.ndjson",
logBytes: 128,
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorUserId: "user-1",
body: "Comment should still be visible",
createdAt: new Date("2026-05-12T23:00:00.000Z"),
updatedAt: new Date("2026-05-12T23:00:00.000Z"),
});
const comments = await svc.listComments(issueId, {
order: "desc",
limit: 50,
});
expect(comments.map((comment) => comment.id)).toEqual([commentId]);
expect(comments[0]?.body).toBe("Comment should still be visible");
expect(comments[0]?.metadata).toBeNull();
});
it("includes blockedBy summaries on list rows in one batched pass", async () => {
const companyId = randomUUID();
const blockerId = randomUUID();
const blockedId = randomUUID();
const unblockedId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: blockerId,
companyId,
title: "Blocker issue",
status: "todo",
priority: "high",
},
{
id: blockedId,
companyId,
title: "Blocked issue",
status: "blocked",
priority: "medium",
},
{
id: unblockedId,
companyId,
title: "Unblocked issue",
status: "todo",
priority: "medium",
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerId,
relatedIssueId: blockedId,
type: "blocks",
});
const defaultResult = await svc.list(companyId);
expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined();
const result = await svc.list(companyId, { includeBlockedBy: true });
const byId = new Map(result.map((issue) => [issue.id, issue]));
expect(byId.get(blockedId)?.blockedBy).toEqual([
expect.objectContaining({
id: blockerId,
identifier: null,
title: "Blocker issue",
status: "todo",
priority: "high",
}),
]);
expect(byId.get(blockerId)?.blockedBy).toEqual([]);
expect(byId.get(unblockedId)?.blockedBy).toEqual([]);
});
it("trims list payload fields that can grow large on issue index routes", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const longDescription = "x".repeat(5_000);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Large issue",
description: longDescription,
status: "todo",
priority: "medium",
executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) },
executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) },
executionWorkspaceSettings: { notes: "w".repeat(2_000) },
});
const [result] = await svc.list(companyId);
expect(result).toBeTruthy();
expect(result?.description).toHaveLength(1200);
expect(result?.descriptionTruncated).toBe(true);
expect(result?.executionPolicy).toBeNull();
expect(result?.executionState).toBeNull();
expect(result?.executionWorkspaceSettings).toBeNull();
});
it("marks list descriptions as not truncated when they fit the preview limit", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const description = "x".repeat(1200);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Exact preview issue",
description,
status: "todo",
priority: "medium",
});
const [result] = await svc.list(companyId);
expect(result?.description).toHaveLength(1200);
expect(result?.descriptionTruncated).toBe(false);
});
it("marks null list descriptions as not truncated", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Null description issue",
description: null,
status: "todo",
priority: "medium",
});
const [result] = await svc.list(companyId);
expect(result?.description).toBeNull();
expect(result?.descriptionTruncated).toBe(false);
});
it("does not let description preview truncation split multibyte characters", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const description = `${"x".repeat(1199)}— still valid after truncation`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Multibyte boundary issue",
description,
status: "todo",
priority: "medium",
});
const [result] = await svc.list(companyId);
expect(result?.description).toHaveLength(1200);
expect(result?.description?.endsWith("—")).toBe(true);
expect(result?.descriptionTruncated).toBe(true);
});
});
describeEmbeddedPostgres("issueService.findOpenAncestorCreatedByAgent", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-ancestor-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedChain() {
const companyId = randomUUID();
const delegatorId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Chain Co",
issuePrefix: `C${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: delegatorId,
companyId,
name: "Delegator",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const rootId = randomUUID();
const midId = randomUUID();
await db.insert(issues).values([
{
id: rootId,
companyId,
title: "Root task",
status: "in_progress",
priority: "medium",
issueNumber: 1,
identifier: "CHAIN-1",
},
{
id: midId,
companyId,
title: "Delegated review",
status: "in_progress",
priority: "medium",
parentId: rootId,
createdByAgentId: delegatorId,
issueNumber: 2,
identifier: "CHAIN-2",
},
]);
return { companyId, delegatorId, rootId, midId };
}
it("finds an open ancestor created by the agent, at any depth", async () => {
const { delegatorId, midId } = await seedChain();
const direct = await svc.findOpenAncestorCreatedByAgent(midId, delegatorId);
expect(direct?.id).toBe(midId);
const other = await svc.findOpenAncestorCreatedByAgent(midId, randomUUID());
expect(other).toBeNull();
});
it("ignores closed ancestors created by the agent", async () => {
const { delegatorId, midId } = await seedChain();
await db.update(issues).set({ status: "done" }).where(eq(issues.id, midId));
expect(await svc.findOpenAncestorCreatedByAgent(midId, delegatorId)).toBeNull();
});
it("finds the ancestor through an arbitrarily deep chain", async () => {
const { companyId, delegatorId, midId } = await seedChain();
// Extend the chain 60 levels below the delegator-created ancestor so the
// match sits far above the new child's parent.
let parentId = midId;
for (let index = 0; index < 60; index += 1) {
const childId = randomUUID();
await db.insert(issues).values({
id: childId,
companyId,
title: `Deep child ${index}`,
status: "in_progress",
priority: "medium",
parentId,
issueNumber: index + 10,
identifier: `CHAIN-${index + 10}`,
});
parentId = childId;
}
const found = await svc.findOpenAncestorCreatedByAgent(parentId, delegatorId);
expect(found?.id).toBe(midId);
});
it("terminates on a corrupted parent-graph cycle", async () => {
const { delegatorId, rootId, midId } = await seedChain();
// Corrupt the graph: root's parent points back at mid.
await db.update(issues).set({ parentId: midId }).where(eq(issues.id, rootId));
const found = await svc.findOpenAncestorCreatedByAgent(midId, delegatorId);
expect(found?.id).toBe(midId);
expect(await svc.findOpenAncestorCreatedByAgent(midId, randomUUID())).toBeNull();
});
});
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(environments);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "workspace-key",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
providerRef: `/tmp/${executionWorkspaceId}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
});
});
it("inherits responsible user for agent-created child issues", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const responsibleUserId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const parent = await svc.create(companyId, {
title: "Parent issue",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
createdByUserId: responsibleUserId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "running",
responsibleUserId,
contextSnapshot: { issueId: parent.id },
});
const child = await svc.create(companyId, {
parentId: parent.id,
title: "Agent-created child",
createdByAgentId: agentId,
actorRunId: runId,
});
expect(parent.responsibleUserId).toBe(responsibleUserId);
expect(child.responsibleUserId).toBe(responsibleUserId);
});
it("creates children under a private issue in the same private subtree", async () => {
const companyId = randomUUID();
const parentIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Private subtree company",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
title: "Private root",
visibility: "private",
privacyRootIssueId: parentIssueId,
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
title: "Inherited private child",
visibility: "open",
});
expect(child.visibility).toBe("private");
expect(child.privacyRootIssueId).toBe(parentIssueId);
});
it("creates one personal private project and parks private tasks there", async () => {
const companyId = randomUUID();
const responsibleUserId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Personal private tasks company",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
});
const [first, second] = await Promise.all([
svc.create(companyId, {
title: "First personal private task",
visibility: "private",
createdByUserId: responsibleUserId,
}),
svc.create(companyId, {
title: "Second personal private task",
visibility: "private",
createdByUserId: responsibleUserId,
}),
]);
const personalProjects = await db.select().from(projects)
.where(eq(projects.personalOwnerUserId, responsibleUserId));
expect(personalProjects).toHaveLength(1);
expect(personalProjects[0]).toMatchObject({
companyId,
name: "My private tasks",
visibility: "private",
});
expect(first.projectId).toBe(personalProjects[0]!.id);
expect(second.projectId).toBe(personalProjects[0]!.id);
expect(first.privacyRootIssueId).toBe(first.id);
expect(second.privacyRootIssueId).toBe(second.id);
await expect(db.select().from(projectAccessMembers)
.where(eq(projectAccessMembers.projectId, personalProjects[0]!.id)))
.resolves.toEqual([
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
]);
});
it("keeps a responsible human and grants an agent access to its explicitly private task", async () => {
const companyId = randomUUID();
const responsibleUserId = randomUUID();
const creatorAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Agent private task company",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
});
await db.insert(agents).values({
id: creatorAgentId,
companyId,
name: "Private task creator",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const issue = await svc.create(companyId, {
title: "Agent email scratch task",
visibility: "private",
createdByAgentId: creatorAgentId,
actorResponsibleUserId: responsibleUserId,
});
expect(issue.responsibleUserId).toBe(responsibleUserId);
const personalProject = await db.select().from(projects)
.where(eq(projects.id, issue.projectId!))
.then((rows) => rows[0]!);
expect(personalProject.personalOwnerUserId).toBe(responsibleUserId);
await expect(db.select().from(projectAccessMembers)
.where(eq(projectAccessMembers.projectId, personalProject.id)))
.resolves.toEqual([
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
]);
await expect(db.select().from(issueAccessGrants)
.where(eq(issueAccessGrants.issueId, issue.id)))
.resolves.toEqual([
expect.objectContaining({
subjectType: "agent",
subjectId: creatorAgentId,
source: "explicit",
}),
]);
});
it("only honors explicit responsibleUserId for trusted issue create callers", async () => {
const companyId = randomUUID();
const creatorUserId = randomUUID();
const requestedResponsibleUserId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const untrusted = await svc.create(companyId, {
title: "Untrusted explicit responsible user",
createdByUserId: creatorUserId,
responsibleUserId: requestedResponsibleUserId,
});
const trusted = await svc.create(companyId, {
title: "Trusted explicit responsible user",
createdByUserId: creatorUserId,
responsibleUserId: requestedResponsibleUserId,
trustExplicitResponsibleUserId: true,
});
expect(untrusted.responsibleUserId).toBe(creatorUserId);
expect(trusted.responsibleUserId).toBe(requestedResponsibleUserId);
});
it("derives responsible user from authenticated actor context without trusting issue body", async () => {
const companyId = randomUUID();
const actorResponsibleUserId = randomUUID();
const requestedResponsibleUserId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const issue = await svc.create(companyId, {
title: "Actor-context responsible user",
responsibleUserId: requestedResponsibleUserId,
actorResponsibleUserId,
});
expect(issue.responsibleUserId).toBe(actorResponsibleUserId);
});
it("does not stamp the assignee default environment onto new issues", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const assigneeEnvironmentId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{
id: assigneeEnvironmentId,
companyId,
name: "QA E2B",
driver: "sandbox",
status: "active",
config: { provider: "e2b" },
},
]);
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA E2B Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: assigneeEnvironmentId,
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
const issue = await svc.create(companyId, {
projectId,
assigneeAgentId,
title: "Environment matrix: e2b / codex_local",
status: "todo",
priority: "medium",
});
expect(issue.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("ignores legacy project environment selection when creating new issues", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const projectEnvironmentId = randomUUID();
const assigneeEnvironmentId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{
id: projectEnvironmentId,
companyId,
name: "QA SSH",
driver: "ssh",
status: "active",
config: {},
},
{
id: assigneeEnvironmentId,
companyId,
name: "QA E2B",
driver: "sandbox",
status: "active",
config: { provider: "e2b" },
},
]);
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA E2B Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: assigneeEnvironmentId,
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
environmentId: projectEnvironmentId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
const issue = await svc.create(companyId, {
projectId,
assigneeAgentId,
title: "Environment matrix: e2b / codex_local",
status: "todo",
priority: "medium",
});
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
});
it("does not rewrite execution workspace settings on reassignment", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const firstEnvironmentId = randomUUID();
const secondEnvironmentId = randomUUID();
const firstAgentId = randomUUID();
const secondAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{
id: firstEnvironmentId,
companyId,
name: "QA SSH",
driver: "ssh",
status: "active",
config: {},
},
{
id: secondEnvironmentId,
companyId,
name: "QA E2B",
driver: "sandbox",
status: "active",
config: { provider: "e2b" },
},
]);
await db.insert(agents).values([
{
id: firstAgentId,
companyId,
name: "QA SSH Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: firstEnvironmentId,
permissions: {},
},
{
id: secondAgentId,
companyId,
name: "QA E2B Codex",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: secondEnvironmentId,
permissions: {},
},
]);
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
const created = await svc.create(companyId, {
projectId,
assigneeAgentId: firstAgentId,
title: "Environment matrix: ssh / codex_local",
status: "todo",
priority: "medium",
});
expect(created.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
const reassigned = await svc.update(created.id, {
assigneeAgentId: secondAgentId,
});
expect(reassigned).not.toBeNull();
expect(reassigned!.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("strips legacy environmentId values from execution workspace settings updates", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const firstEnvironmentId = randomUUID();
const secondEnvironmentId = randomUUID();
const operatorEnvironmentId = randomUUID();
const firstAgentId = randomUUID();
const secondAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(environments).values([
{ id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} },
{ id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
{ id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
]);
await db.insert(agents).values([
{
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
defaultEnvironmentId: firstEnvironmentId, permissions: {},
},
{
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
defaultEnvironmentId: secondEnvironmentId, permissions: {},
},
]);
await db.insert(projects).values({
id: projectId, companyId, name: "Workspace project", status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
defaultProjectWorkspaceId: projectWorkspaceId,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
});
const created = await svc.create(companyId, {
projectId,
assigneeAgentId: firstAgentId,
title: "Operator overrides env then reassigns",
status: "todo",
priority: "medium",
});
const overridden = await svc.update(created.id, {
executionWorkspaceSettings: {
mode: "shared_workspace",
environmentId: operatorEnvironmentId,
},
});
expect(overridden!.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
const reassigned = await svc.update(created.id, {
assigneeAgentId: secondAgentId,
});
expect(reassigned!.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const parentProjectWorkspaceId = randomUUID();
const parentExecutionWorkspaceId = randomUUID();
const explicitProjectWorkspaceId = randomUUID();
const explicitExecutionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values([
{
id: parentProjectWorkspaceId,
companyId,
projectId,
name: "Parent workspace",
},
{
id: explicitProjectWorkspaceId,
companyId,
projectId,
name: "Explicit workspace",
},
]);
await db.insert(executionWorkspaces).values([
{
id: parentExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Parent worktree",
status: "active",
providerType: "git_worktree",
},
{
id: explicitExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: explicitProjectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Explicit shared workspace",
status: "active",
providerType: "local_fs",
},
]);
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId: parentExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
projectWorkspaceId: explicitProjectWorkspaceId,
executionWorkspaceId: explicitExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "shared_workspace",
},
});
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const sourceIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "operator_branch",
strategyType: "git_worktree",
name: "Operator branch",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source issue",
status: "todo",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "operator_branch",
},
});
const followUp = await svc.create(companyId, {
projectId,
title: "Follow-up issue",
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
});
expect(followUp.parentId).toBeNull();
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
expect(followUp.executionWorkspaceSettings).toEqual({
mode: "operator_branch",
});
});
it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const goalId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Ship child helpers",
level: "task",
status: "active",
});
await db.insert(projects).values({
id: projectId,
companyId,
goalId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
providerRef: `/tmp/${executionWorkspaceId}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 1,
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, {
title: "Child helper",
status: "todo",
description: "Implement the helper.",
acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"],
blockParentUntilDone: true,
});
expect(parentBlockerAdded).toBe(true);
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(projectId);
expect(child.goalId).toBe(goalId);
expect(child.requestDepth).toBe(2);
expect(child.description).toContain("## Acceptance Criteria");
expect(child.description).toContain("- Uses the parent issue as parentId");
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
const parentRelations = await svc.getRelationSummaries(parentIssueId);
expect(parentRelations.blockedBy).toEqual([
expect.objectContaining({
id: child.id,
title: "Child helper",
}),
]);
});
it("createChild preserves strategy-only workspace intent without realizing the parent workspace", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Accepted plan parent",
status: "in_progress",
priority: "medium",
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId,
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
});
const { issue: child } = await svc.createChild(parentIssueId, {
title: "Accepted plan child",
status: "todo",
priority: "medium",
executionWorkspaceInheritanceMode: "strategy_only",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(projectId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBeNull();
expect(child.executionWorkspacePreference).toBeNull();
expect(child.executionWorkspaceSettings).toEqual({
mode: "isolated_workspace",
environmentId,
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
});
});
it("createChild targeting another project does not forward the parent project workspace", async () => {
const companyId = randomUUID();
const parentProjectId = randomUUID();
const targetProjectId = randomUUID();
const parentIssueId = randomUUID();
const parentProjectWorkspaceId = randomUUID();
const targetProjectWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values([
{ id: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" },
{ id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" },
]);
await db.insert(projectWorkspaces).values([
{
id: parentProjectWorkspaceId,
companyId,
projectId: parentProjectId,
name: "paperclip",
isPrimary: true,
},
{
id: targetProjectWorkspaceId,
companyId,
projectId: targetProjectId,
name: "paperclip-id",
isPrimary: true,
},
]);
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId: parentProjectId,
projectWorkspaceId: parentProjectWorkspaceId,
title: "Google Workspace MCP",
status: "in_progress",
priority: "medium",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree", baseRef: "origin/master" },
},
});
const { issue: child } = await svc.createChild(parentIssueId, {
title: "Implement the Paperclip ID connect broker",
status: "todo",
priority: "medium",
projectId: targetProjectId,
executionWorkspaceInheritanceMode: "strategy_only",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(targetProjectId);
expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId);
});
it("clamps helper-created child requestDepth to the safe maximum", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const goalId = randomUUID();
const parentIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Ship child helpers",
level: "task",
status: "active",
});
await db.insert(projects).values({
id: projectId,
companyId,
goalId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: MAX_ISSUE_REQUEST_DEPTH,
});
const { issue: child } = await svc.createChild(parentIssueId, {
title: "Child helper",
status: "todo",
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 100,
});
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
});
});
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedSharedWorkspaceDependency() {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const blockerId = randomUUID();
const dependentId = randomUUID();
const foreignIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Shared workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Shared workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Shared exec workspace",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values([
{
id: blockerId,
companyId,
projectId,
identifier: "PAP-15043",
title: "Predecessor",
status: "done",
priority: "medium",
executionWorkspaceId,
},
{
id: dependentId,
companyId,
projectId,
identifier: "PAP-15046",
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
{
id: foreignIssueId,
companyId,
projectId,
identifier: "PAP-15125",
title: "Foreign in-flight issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
},
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
return {
companyId,
assigneeAgentId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
blockerId,
dependentId,
foreignIssueId,
};
}
it("returns authoritative update receipts for row fields and blocker relations", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const blockerId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: issueId,
companyId,
title: "Receipt issue",
description: "old description",
status: "todo",
priority: "medium",
},
{
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
},
]);
const fieldUpdate = await svc.update(issueId, {
title: "Receipt issue",
priority: "high",
description: "new description",
});
expect(fieldUpdate?.changes).toEqual({
priority: { from: "medium", to: "high" },
description: { from: "old description", to: "new description", updated: true },
});
const blockersSet = await svc.update(issueId, { blockedByIssueIds: [blockerId, blockerId] });
expect(blockersSet?.blockedByIssueIds).toEqual([blockerId]);
expect(blockersSet?.changes.blockedByIssueIds).toEqual({ from: [], to: [blockerId] });
const blockersCleared = await svc.update(issueId, { blockedByIssueIds: [] });
expect(blockersCleared?.blockedByIssueIds).toEqual([]);
expect(blockersCleared?.changes.blockedByIssueIds).toEqual({ from: [blockerId], to: [] });
await db.update(issues).set({
title: "Concurrent receipt issue",
priority: "medium",
}).where(eq(issues.id, issueId));
const [titleUpdate, priorityUpdate] = await Promise.all([
svc.update(issueId, { title: "Concurrent title" }),
svc.update(issueId, { priority: "high" }),
]);
expect(titleUpdate?.changes).toEqual({
title: { from: "Concurrent receipt issue", to: "Concurrent title" },
});
expect(priorityUpdate?.changes).toEqual({
priority: { from: "medium", to: "high" },
});
});
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const blockerId = randomUUID();
const blockedId = randomUUID();
await db.insert(issues).values([
{
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
},
{
id: blockedId,
companyId,
title: "Blocked issue",
status: "blocked",
priority: "medium",
},
]);
await svc.update(blockedId, {
blockedByIssueIds: [blockerId],
});
const blockerRelations = await svc.getRelationSummaries(blockerId);
const blockedRelations = await svc.getRelationSummaries(blockedId);
expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]);
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
});
it("returns blocked-by summaries on newly created issues", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const blockerId = randomUUID();
await db.insert(issues).values({
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
});
const created = await svc.create(companyId, {
title: "Blocked issue",
status: "blocked",
priority: "medium",
blockedByIssueIds: [blockerId],
});
expect(created.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
expect(created.blockedBy[0]).toEqual(expect.objectContaining({
title: "Blocker",
status: "todo",
priority: "high",
}));
expect(created.blocks).toEqual([]);
});
it("returns blocked-by summaries on newly created child issues", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const parentId = randomUUID();
const blockerId = randomUUID();
await db.insert(issues).values([
{
id: parentId,
companyId,
title: "Parent",
status: "todo",
priority: "medium",
},
{
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
},
]);
const { issue: child } = await svc.createChild(parentId, {
title: "Blocked child issue",
status: "blocked",
priority: "medium",
blockedByIssueIds: [blockerId],
});
expect(child.parentId).toBe(parentId);
expect(child.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
expect(child.blocks).toEqual([]);
});
it("returns blocks summaries when child creation blocks the parent", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const parentId = randomUUID();
await db.insert(issues).values({
id: parentId,
companyId,
title: "Parent",
status: "todo",
priority: "medium",
});
const { issue: child } = await svc.createChild(parentId, {
title: "Parent-blocking child",
status: "todo",
priority: "medium",
blockParentUntilDone: true,
});
expect(child.blocks.map((relation) => relation.id)).toEqual([parentId]);
expect(child.blocks[0]).toEqual(expect.objectContaining({
title: "Parent",
status: "todo",
priority: "medium",
}));
expect(child.blockedBy).toEqual([]);
});
it("adds terminal blockers to immediate blocked-by summaries", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const issueA = randomUUID();
const issueB = randomUUID();
const issueC = randomUUID();
const issueD = randomUUID();
await db.insert(issues).values([
{ id: issueA, companyId, identifier: "PAP-1", title: "Issue A", status: "blocked", priority: "medium" },
{ id: issueB, companyId, identifier: "PAP-2", title: "Issue B", status: "blocked", priority: "medium" },
{ id: issueC, companyId, identifier: "PAP-3", title: "Issue C", status: "blocked", priority: "medium" },
{ id: issueD, companyId, identifier: "PAP-4", title: "Issue D", status: "todo", priority: "high" },
]);
await svc.update(issueC, { blockedByIssueIds: [issueD] });
await svc.update(issueB, { blockedByIssueIds: [issueC] });
await svc.update(issueA, { blockedByIssueIds: [issueB] });
const relations = await svc.getRelationSummaries(issueA);
expect(relations.blockedBy).toHaveLength(1);
expect(relations.blockedBy[0]).toMatchObject({
id: issueB,
identifier: "PAP-2",
title: "Issue B",
terminalBlockers: [
expect.objectContaining({
id: issueD,
identifier: "PAP-4",
title: "Issue D",
status: "todo",
priority: "high",
}),
],
});
});
it("rejects blocking cycles", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const issueA = randomUUID();
const issueB = randomUUID();
await db.insert(issues).values([
{ id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" },
{ id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" },
]);
await svc.update(issueA, { blockedByIssueIds: [issueB] });
await expect(
svc.update(issueB, { blockedByIssueIds: [issueA] }),
).rejects.toMatchObject({ status: 422 });
});
it("only returns dependents once every blocker is done", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const blockerA = randomUUID();
const blockerB = randomUUID();
const blockedIssueId = randomUUID();
await db.insert(issues).values([
{ id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" },
{ id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" },
{
id: blockedIssueId,
companyId,
title: "Blocked issue",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
]);
await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] });
expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]);
await svc.update(blockerB, { status: "done" });
await expect(svc.listWakeableBlockedDependents(blockerA)).resolves.toEqual([
expect.objectContaining({
id: blockedIssueId,
assigneeAgentId,
blockerIssueIds: expect.arrayContaining([blockerA, blockerB]),
}),
]);
});
it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
foreignIssueId,
} = await seedSharedWorkspaceDependency();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: foreignIssueId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
blockerIssueIds: [blockerId],
}),
]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
unresolvedBlockerIssueIds: [],
});
});
it("keeps dependents blocked on unattributed workspace operations for the blocker workspace", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
} = await seedSharedWorkspaceDependency();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: null,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: false,
pendingFinalizeBlockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [blockerId],
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: null,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
blockerIssueIds: [blockerId],
}),
]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
unresolvedBlockerIssueIds: [],
});
});
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
foreignIssueId,
assigneeAgentId,
} = await seedSharedWorkspaceDependency();
// The blocker touched its workspace but has not yet recorded
// workspace_finalize — the dependent must NOT wake.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: false,
pendingFinalizeBlockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [blockerId],
});
// A failed finalize must keep the gate closed.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
const pendingDependent = (await svc.list(companyId, { status: "blocked" }))
.find((issue) => issue.id === dependentId);
expect(pendingDependent?.blockerAttention).toMatchObject({
state: "needs_attention",
unresolvedBlockerCount: 1,
attentionBlockerCount: 1,
pendingFinalizeBlockerIssueIds: [blockerId],
sampleBlockerIdentifier: "PAP-15043",
});
await expect(
svc.checkout(dependentId, assigneeAgentId, ["blocked"], null),
).rejects.toMatchObject({
status: 422,
details: {
unresolvedBlockerIssueIds: [blockerId],
unresolvedBlockers: [{
issueId: blockerId,
identifier: "PAP-15043",
title: "Predecessor",
reason: "pending_finalize",
}],
},
});
// A later successful finalize on the same workspace, even when attributed
// to another issue, proves the shared branch is coherent past the failure.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: foreignIssueId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: null,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:15:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
assigneeAgentId,
blockerIssueIds: [blockerId],
}),
]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
});
});
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const blockerId = randomUUID();
const dependentId = randomUUID();
await db.insert(issues).values([
// Done blocker with no execution workspace ever attached (e.g. closed manually).
{ id: blockerId, companyId, title: "Manual done blocker", status: "done", priority: "medium" },
{
id: dependentId,
companyId,
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
// No executionWorkspaceId → no barrier → dependent should be wakeable.
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
assigneeAgentId,
blockerIssueIds: [blockerId],
}),
]);
});
it("reports dependency readiness for blocked issue chains", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const blockerId = randomUUID();
const blockedId = randomUUID();
await db.insert(issues).values([
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
{ id: blockedId, companyId, title: "Blocked", status: "todo", priority: "medium" },
]);
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
issueId: blockedId,
blockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [blockerId],
unresolvedBlockerCount: 1,
allBlockersDone: false,
isDependencyReady: false,
});
await svc.update(blockerId, { status: "done" });
await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({
issueId: blockedId,
blockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
allBlockersDone: true,
isDependencyReady: true,
});
});
it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const sourceIssueId = randomUUID();
const recoveryIssueId = randomUUID();
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
title: "Source issue",
status: "blocked",
priority: "medium",
},
{
id: recoveryIssueId,
companyId,
title: "Liveness escalation issue",
status: "in_progress",
priority: "high",
originKind: "harness_liveness_escalation",
originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`,
},
]);
await svc.update(sourceIssueId, {
blockedByIssueIds: [recoveryIssueId],
});
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
blockedBy: [expect.objectContaining({ id: recoveryIssueId })],
});
await svc.update(recoveryIssueId, {
status: "done",
});
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
blockedBy: [],
});
});
it("rejects execution when unresolved blockers remain", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const blockerId = randomUUID();
const blockedId = randomUUID();
await db.insert(issues).values([
{ id: blockerId, companyId, identifier: "PAP-1", title: "Blocker", status: "todo", priority: "medium" },
{
id: blockedId,
companyId,
title: "Blocked",
status: "todo",
priority: "medium",
assigneeAgentId,
},
]);
await svc.update(blockedId, { blockedByIssueIds: [blockerId] });
await expect(
svc.update(blockedId, { status: "in_progress" }),
).rejects.toMatchObject({ status: 422 });
await expect(
svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null),
).rejects.toMatchObject({
status: 422,
details: {
unresolvedBlockerIssueIds: [blockerId],
unresolvedBlockers: [{
issueId: blockerId,
identifier: "PAP-1",
title: "Blocker",
reason: "not_done",
}],
},
});
});
it("wakes parents only when all direct children are terminal", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const parentId = randomUUID();
const childA = randomUUID();
const childB = randomUUID();
await db.insert(issues).values([
{
id: parentId,
companyId,
title: "Parent issue",
status: "todo",
priority: "medium",
assigneeAgentId,
},
{
id: childA,
companyId,
parentId,
title: "Child A",
status: "done",
priority: "medium",
// Give the children distinct, ordered issue numbers. The service
// sorts direct children by issueNumber, then createdAt. A batched
// insert gives every row in the statement the same defaultNow()
// createdAt, so without a distinct issueNumber the two children
// tie on both sort keys and the database is free to return them
// in either order.
issueNumber: 1,
},
{
id: childB,
companyId,
parentId,
title: "Child B",
status: "blocked",
priority: "medium",
issueNumber: 2,
},
]);
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull();
await svc.update(childB, { status: "cancelled" });
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({
id: parentId,
assigneeAgentId,
childIssueIds: [childA, childB],
childIssueSummaries: [
expect.objectContaining({ id: childA, title: "Child A", status: "done" }),
expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }),
],
childIssueSummaryTruncated: false,
});
});
});
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "workspace-key",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
providerRef: `/tmp/${executionWorkspaceId}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
});
});
it("preserves the parent project when a generic child create inherits workspace linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
title: "Generic child issue",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(projectId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
});
it("uses the target project's own workspaces for a cross-project child instead of inheriting the parent's", async () => {
const companyId = randomUUID();
const parentProjectId = randomUUID();
const targetProjectId = randomUUID();
const parentIssueId = randomUUID();
const parentProjectWorkspaceId = randomUUID();
const targetProjectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values([
{ id: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" },
{ id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" },
]);
await db.insert(projectWorkspaces).values([
{
id: parentProjectWorkspaceId,
companyId,
projectId: parentProjectId,
name: "paperclip",
isPrimary: true,
},
{
id: targetProjectWorkspaceId,
companyId,
projectId: targetProjectId,
name: "paperclip-id",
isPrimary: true,
},
]);
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId: parentProjectId,
projectWorkspaceId: parentProjectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId: parentProjectId,
projectWorkspaceId: parentProjectWorkspaceId,
title: "Google Workspace MCP",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId: targetProjectId,
title: "Implement the Paperclip ID connect broker",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(targetProjectId);
expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId);
expect(child.executionWorkspaceId).not.toBe(executionWorkspaceId);
});
it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await expect(svc.create(companyId, {
title: "Projectless isolated worktree",
status: "todo",
priority: "medium",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree" },
},
})).rejects.toMatchObject({
status: 422,
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
details: {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
},
});
});
it("does not reject ambiguous inherited git-worktree settings before dispatch", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
const issue = await svc.create(companyId, {
title: "Ambiguous inherited worktree",
status: "todo",
priority: "medium",
executionWorkspaceSettings: {
mode: "inherit",
workspaceStrategy: { type: "git_worktree" },
},
});
expect(issue.executionWorkspaceSettings).toEqual({
mode: "inherit",
workspaceStrategy: { type: "git_worktree" },
});
});
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const parentProjectWorkspaceId = randomUUID();
const parentExecutionWorkspaceId = randomUUID();
const explicitProjectWorkspaceId = randomUUID();
const explicitExecutionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values([
{
id: parentProjectWorkspaceId,
companyId,
projectId,
name: "Parent workspace",
},
{
id: explicitProjectWorkspaceId,
companyId,
projectId,
name: "Explicit workspace",
},
]);
await db.insert(executionWorkspaces).values([
{
id: parentExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Parent worktree",
status: "active",
providerType: "git_worktree",
},
{
id: explicitExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: explicitProjectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Explicit shared workspace",
status: "active",
providerType: "local_fs",
},
]);
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId: parentExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
projectWorkspaceId: explicitProjectWorkspaceId,
executionWorkspaceId: explicitExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "shared_workspace",
},
});
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const sourceIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "operator_branch",
strategyType: "git_worktree",
name: "Operator branch",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source issue",
status: "todo",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "operator_branch",
},
});
const followUp = await svc.create(companyId, {
title: "Follow-up issue",
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
});
expect(followUp.parentId).toBeNull();
expect(followUp.projectId).toBe(projectId);
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
expect(followUp.executionWorkspaceSettings).toEqual({
mode: "operator_branch",
});
});
it("derives project identity when an update adds workspace linkage to a projectless issue", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Projectless issue",
status: "todo",
priority: "medium",
});
const updated = await svc.update(issueId, {
projectWorkspaceId,
});
expect(updated?.projectId).toBe(projectId);
expect(updated?.projectWorkspaceId).toBe(projectWorkspaceId);
});
it("rejects updates that pin a projectless issue to an isolated git worktree", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(agents).values({
id: agentId,
companyId,
name: "Workspace Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const issue = await svc.create(companyId, {
title: "Assign then isolate",
status: "todo",
priority: "medium",
});
await expect(svc.update(issue.id, {
assigneeAgentId: agentId,
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree" },
},
})).rejects.toMatchObject({
status: 422,
message: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
details: {
code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION,
},
});
});
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
metadata: {
config: {
environmentId: "env-old",
provisionCommand: "bash ./scripts/provision-old.sh",
teardownCommand: "bash ./scripts/teardown-old.sh",
workspaceRuntime: { profile: "old" },
},
},
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Recovery issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId: "env-old",
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "bash ./scripts/provision-old.sh",
teardownCommand: "bash ./scripts/teardown-old.sh",
},
workspaceRuntime: { profile: "old" },
},
});
await svc.update(issueId, {
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId: "env-new",
workspaceStrategy: {
type: "cloud_sandbox",
provisionCommand: "bash ./scripts/provision-new.sh",
teardownCommand: "bash ./scripts/teardown-new.sh",
},
workspaceRuntime: { profile: "new" },
},
});
const workspace = await db
.select({ metadata: executionWorkspaces.metadata })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
expect(workspace?.metadata).toMatchObject({
config: {
environmentId: null,
provisionCommand: "bash ./scripts/provision-new.sh",
runtimeProvisionCommand: null,
teardownCommand: "bash ./scripts/teardown-new.sh",
cleanupCommand: null,
workspaceRuntime: { profile: "new" },
desiredState: null,
serviceStates: null,
},
});
});
});
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("can skip comment-body scans for bounded issue detail reads", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const titleProjectId = randomUUID();
const commentProjectId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values([
{
id: titleProjectId,
companyId,
name: "Title project",
status: "in_progress",
},
{
id: commentProjectId,
companyId,
name: "Comment project",
status: "in_progress",
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`,
description: null,
status: "todo",
priority: "medium",
});
await db.insert(issueComments).values({
companyId,
issueId,
body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`,
});
expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]);
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
titleProjectId,
commentProjectId,
]);
});
it("returns multiple same-company mentions in order, deduped", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
const firstProjectId = randomUUID();
const secondProjectId = randomUUID();
const thirdProjectId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values([
{ id: firstProjectId, companyId, name: "First project", status: "in_progress" },
{ id: secondProjectId, companyId, name: "Second project", status: "in_progress" },
{ id: thirdProjectId, companyId, name: "Third project", status: "in_progress" },
]);
await db.insert(issues).values({
id: issueId,
companyId,
title:
`See [First](${buildProjectMentionHref(firstProjectId)}) and ` +
`[Second](${buildProjectMentionHref(secondProjectId)})`,
description: null,
status: "todo",
priority: "medium",
});
await db.insert(issueComments).values({
companyId,
issueId,
// Repeats the first mention (deduped) and introduces a third.
body:
`Also [First again](${buildProjectMentionHref(firstProjectId)}) and ` +
`[Third](${buildProjectMentionHref(thirdProjectId)})`,
});
expect(await svc.findMentionedProjectIds(issueId)).toEqual([
firstProjectId,
secondProjectId,
thirdProjectId,
]);
});
it("filters out a mention from another company", async () => {
const companyId = randomUUID();
const foreignCompanyId = randomUUID();
const issueId = randomUUID();
const sameCompanyProjectId = randomUUID();
const foreignProjectId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: foreignCompanyId,
name: "Other company",
issuePrefix: `F${foreignCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(projects).values([
{ id: sameCompanyProjectId, companyId, name: "Same-company project", status: "in_progress" },
{ id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign project", status: "in_progress" },
]);
await db.insert(issues).values({
id: issueId,
companyId,
title:
`Ours [Same](${buildProjectMentionHref(sameCompanyProjectId)}) and ` +
`theirs [Foreign](${buildProjectMentionHref(foreignProjectId)})`,
description: null,
status: "todo",
priority: "medium",
});
expect(await svc.findMentionedProjectIds(issueId)).toEqual([sameCompanyProjectId]);
});
});
describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-execution-lock-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedIssueWithRun(status: string | null) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = status ? randomUUID() : null;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
if (runId) {
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status,
invocationSource: "manual",
});
}
await db.insert(issues).values({
id: issueId,
companyId,
title: "Execution lock",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: runId,
executionAgentNameKey: runId ? "codexcoder" : null,
executionLockedAt: runId ? new Date() : null,
});
return { issueId, runId };
}
it("clears execution locks owned by terminal runs", async () => {
const { issueId } = await seedIssueWithRun("failed");
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(true);
const row = await db
.select({
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toEqual({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
});
});
it("does not clear execution locks owned by live runs", async () => {
const { issueId, runId } = await seedIssueWithRun("running");
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
const row = await db
.select({
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row?.executionRunId).toBe(runId);
expect(row?.executionAgentNameKey).toBe("codexcoder");
expect(row?.executionLockedAt).toBeInstanceOf(Date);
});
it("does not update issues without an execution lock", async () => {
const { issueId } = await seedIssueWithRun(null);
await expect(svc.clearExecutionRunIfTerminal(issueId)).resolves.toBe(false);
const row = await db
.select({ executionRunId: issues.executionRunId, executionLockedAt: issues.executionLockedAt })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toEqual({ executionRunId: null, executionLockedAt: null });
});
it("does not clear checkout locks when a different execution run is live", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const failedRunId = randomUUID();
const runningRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
{
id: failedRunId,
companyId,
agentId,
status: "failed",
invocationSource: "manual",
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
},
{
id: runningRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date("2026-06-10T10:06:00.000Z"),
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Mixed execution lock",
status: "in_progress",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: failedRunId,
executionRunId: runningRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-06-10T10:06:00.000Z"),
});
await expect(svc.clearCheckoutRunIfTerminal(issueId)).resolves.toBe(false);
const row = await db
.select({
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
executionLockedAt: issues.executionLockedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row?.checkoutRunId).toBe(failedRunId);
expect(row?.executionRunId).toBe(runningRunId);
expect(row?.executionAgentNameKey).toBe("codexcoder");
expect(row?.executionLockedAt).toBeInstanceOf(Date);
});
it("does not let stale release clobber a successor checkout lock", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const failedRunId = randomUUID();
const releasingRunId = randomUUID();
const successorRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
{
id: failedRunId,
companyId,
agentId,
status: "failed",
invocationSource: "manual",
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
},
{
id: releasingRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date("2026-06-10T10:06:00.000Z"),
},
{
id: successorRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date("2026-06-10T10:07:00.000Z"),
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Race stale release",
status: "in_progress",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: failedRunId,
executionRunId: failedRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
});
const [releaseResult, checkoutResult] = await Promise.allSettled([
svc.release(issueId, agentId, releasingRunId),
svc.checkout(issueId, agentId, ["todo", "in_progress"], successorRunId),
]);
expect(checkoutResult.status).toBe("fulfilled");
if (releaseResult.status === "rejected") {
expect(releaseResult.reason).toMatchObject({ status: 409 });
}
const row = await db
.select({
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toMatchObject({
status: "in_progress",
assigneeAgentId: agentId,
checkoutRunId: successorRunId,
executionRunId: successorRunId,
});
});
it("checkout refuses to promote a 'done' issue when 'done' is not in expectedStatuses, even with a lingering executionRunId pointer", async () => {
// Regression for PR #2482 checkout-adoption review finding: the original
// patch's stale-executionRunId adoption SQL set `status: 'in_progress'`
// unconditionally, bypassing the caller's expectedStatuses guard. With the
// guard restored, attempting to take over a 'done' issue with
// expectedStatuses=['todo'] must fail and leave the row untouched.
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const failedRunId = randomUUID();
const successorRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
{
id: failedRunId,
companyId,
agentId,
status: "failed",
invocationSource: "manual",
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
},
{
id: successorRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date("2026-06-10T10:07:00.000Z"),
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Stale lock on done issue",
status: "done",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: failedRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
completedAt: new Date("2026-06-10T10:01:00.000Z"),
});
await expect(svc.checkout(issueId, agentId, ["todo"], successorRunId))
.rejects.toMatchObject({ status: 409 });
const row = await db
.select({
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toMatchObject({
status: "done",
assigneeAgentId: agentId,
checkoutRunId: null,
});
});
it("checkout adoption of a stale checkoutRunId preserves the issue's assigneeUserId", async () => {
// Regression for PR #2482 checkout-adoption review finding: any adoption
// helper that re-locks an existing in_progress issue (e.g. when the prior
// checkout/execution run is terminal) must not strip the row's
// assigneeUserId. We exercise this via the adoptStaleCheckoutRun path,
// which fires when checkoutRunId points at a terminal run while
// executionRunId still points at a different, non-terminal run.
const companyId = randomUUID();
const agentId = randomUUID();
const userId = randomUUID();
const issueId = randomUUID();
const failedCheckoutRunId = randomUUID();
const queuedExecutionRunId = randomUUID();
const successorRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
{
id: failedCheckoutRunId,
companyId,
agentId,
status: "failed",
invocationSource: "manual",
finishedAt: new Date("2026-06-10T10:05:00.000Z"),
},
{
id: queuedExecutionRunId,
companyId,
agentId,
status: "queued",
invocationSource: "manual",
},
{
id: successorRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date("2026-06-10T10:07:00.000Z"),
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Stale checkout lock with user co-assignee",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
assigneeUserId: userId,
checkoutRunId: failedCheckoutRunId,
executionRunId: queuedExecutionRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-06-10T10:00:00.000Z"),
});
const result = await svc.checkout(issueId, agentId, ["todo", "in_progress"], successorRunId);
expect(result).toBeTruthy();
const row = await db
.select({
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toMatchObject({
status: "in_progress",
assigneeAgentId: agentId,
assigneeUserId: userId,
checkoutRunId: successorRunId,
executionRunId: successorRunId,
});
});
});
describeEmbeddedPostgres("accepted plan decomposition", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-decomposition-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issuePlanDecompositions);
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedAcceptedPlanContext() {
const companyId = randomUUID();
const goalId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Accepted plan decomposition",
level: "task",
status: "active",
});
return { companyId, goalId, assigneeAgentId };
}
async function seedAcceptedPlanIssue(args?: {
companyId?: string;
goalId?: string;
assigneeAgentId?: string;
sourceIssueId?: string;
issueTitle?: string;
workMode?: IssueWorkMode;
}) {
const companyId = args?.companyId ?? randomUUID();
const goalId = args?.goalId ?? randomUUID();
const assigneeAgentId = args?.assigneeAgentId ?? randomUUID();
const sourceIssueId = args?.sourceIssueId ?? randomUUID();
const planDocumentId = randomUUID();
const acceptedPlanRevisionId = randomUUID();
const acceptedInteractionId = randomUUID();
if (!args?.companyId || !args?.goalId || !args?.assigneeAgentId) {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Accepted plan decomposition",
level: "task",
status: "active",
});
}
await db.insert(issues).values({
id: sourceIssueId,
companyId,
goalId,
title: args?.issueTitle ?? "Planning issue",
status: "in_progress",
priority: "medium",
workMode: args?.workMode ?? "planning",
assigneeAgentId: assigneeAgentId,
});
await db.insert(documents).values({
id: planDocumentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "Plan body",
latestRevisionId: acceptedPlanRevisionId,
latestRevisionNumber: 1,
createdByAgentId: assigneeAgentId,
updatedByAgentId: assigneeAgentId,
});
await db.insert(documentRevisions).values({
id: acceptedPlanRevisionId,
companyId,
documentId: planDocumentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "Plan body",
createdByAgentId: assigneeAgentId,
});
await db.insert(issueDocuments).values({
companyId,
issueId: sourceIssueId,
documentId: planDocumentId,
key: "plan",
});
await db.insert(issueThreadInteractions).values({
id: acceptedInteractionId,
companyId,
issueId: sourceIssueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId: sourceIssueId,
documentId: planDocumentId,
key: "plan",
revisionId: acceptedPlanRevisionId,
revisionNumber: 1,
},
},
result: {
version: 1,
outcome: "accepted",
},
resolvedAt: new Date(),
createdByUserId: "local-board",
resolvedByUserId: "local-board",
});
return { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId };
}
async function getAcceptedPlanClaim(sourceIssueId: string) {
return db
.select()
.from(issuePlanDecompositions)
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId))
.then((rows) => rows[0] ?? null);
}
it("reuses the same child issue set on repeat decomposition attempts for an accepted plan revision", async () => {
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
const children = [
{
title: "Implement the claim table",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
assigneeAgentId,
},
{
title: "Add decomposition route tests",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
];
const first = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
});
expect(first.decomposition).not.toHaveProperty("requestedChildren");
expect(first.childIssueIds).toHaveLength(2);
expect(first.newlyCreatedIssues).toHaveLength(2);
expect(first.decomposition.status).toBe("completed");
const second = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
});
expect(second.childIssueIds).toEqual(first.childIssueIds);
expect(second.newlyCreatedIssues).toHaveLength(0);
expect(second.decomposition.status).toBe("completed");
const persistedClaims = await db
.select()
.from(issuePlanDecompositions)
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId));
expect(persistedClaims).toHaveLength(1);
expect(persistedClaims[0]?.requestedChildCount).toBe(2);
expect(persistedClaims[0]?.childIssueIds).toEqual(first.childIssueIds);
const childrenRows = await db
.select({ id: issues.id, title: issues.title })
.from(issues)
.where(eq(issues.parentId, sourceIssueId));
expect(childrenRows).toHaveLength(2);
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
const companyIssues = await svc.list(companyId, { parentId: sourceIssueId });
expect(companyIssues).toHaveLength(2);
});
it("rejects a different child set for the same accepted plan fingerprint", async () => {
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: [
{
title: "Implement the claim table",
status: "todo",
workMode: "standard",
priority: "medium",
},
],
actorAgentId: assigneeAgentId,
});
await expect(svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: [
{
title: "Implement the claim table",
status: "todo",
workMode: "standard",
priority: "medium",
},
{
title: "This duplicate should be rejected",
status: "todo",
workMode: "standard",
priority: "medium",
},
],
actorAgentId: assigneeAgentId,
})).rejects.toMatchObject({
status: 409,
});
});
it("allows accepted-plan decomposition on a standard-work issue with an accepted plan document", async () => {
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue({
workMode: "standard",
issueTitle: "Implement after planning",
});
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: [
{
title: "Implement the approved first slice",
status: "todo",
workMode: "standard",
priority: "medium",
},
],
actorAgentId: assigneeAgentId,
});
expect(result.childIssueIds).toHaveLength(1);
expect(result.newlyCreatedIssues).toHaveLength(1);
expect(result.decomposition.status).toBe("completed");
});
it("serializes concurrent accepted-plan retries for the same parent issue without duplicate children", async () => {
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
const children = [
{
title: "Persist exact-once decomposition claim",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
{
title: "Guard concurrent retry callers",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
];
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
});
const claim = await getAcceptedPlanClaim(sourceIssueId);
expect(claim).not.toBeNull();
for (const childIssueId of initial.childIssueIds) {
await db.delete(issues).where(eq(issues.id, childIssueId));
}
await db
.update(issuePlanDecompositions)
.set({
status: "in_flight",
childIssueIds: [],
completedAt: null,
updatedAt: new Date(),
})
.where(eq(issuePlanDecompositions.id, claim!.id));
const svcA = issueService(db);
const svcB = issueService(db);
const [first, second] = await Promise.all([
svcA.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
}),
svcB.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
}),
]);
expect(first.childIssueIds).toEqual(second.childIssueIds);
expect(first.childIssueIds).toHaveLength(2);
expect(first.newlyCreatedIssues.length + second.newlyCreatedIssues.length).toBe(2);
const persistedClaim = await getAcceptedPlanClaim(sourceIssueId);
expect(persistedClaim?.status).toBe("completed");
expect(persistedClaim?.childIssueIds).toEqual(first.childIssueIds);
const childrenRows = await db
.select({ id: issues.id, title: issues.title })
.from(issues)
.where(eq(issues.parentId, sourceIssueId));
expect(childrenRows).toHaveLength(2);
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
});
it("rejects another planning parent's accepted revision even when both issues share the assignee", async () => {
const { companyId, goalId, assigneeAgentId } = await seedAcceptedPlanContext();
const firstIssue = await seedAcceptedPlanIssue({
companyId,
goalId,
assigneeAgentId,
issueTitle: "Earlier accepted plan",
});
const secondIssue = await seedAcceptedPlanIssue({
companyId,
goalId,
assigneeAgentId,
issueTitle: "Later accepted plan",
});
await svc.decomposeAcceptedPlan(firstIssue.sourceIssueId, {
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
children: [
{
title: "Decompose the first issue only",
status: "todo",
workMode: "standard",
priority: "medium",
},
],
actorAgentId: assigneeAgentId,
});
await expect(svc.decomposeAcceptedPlan(secondIssue.sourceIssueId, {
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
children: [
{
title: "This must not land on the second parent",
status: "todo",
workMode: "standard",
priority: "medium",
},
],
actorAgentId: assigneeAgentId,
})).rejects.toMatchObject({
status: 422,
});
const secondIssueChildren = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.parentId, secondIssue.sourceIssueId));
expect(secondIssueChildren).toHaveLength(0);
});
it("resumes partial child creation under the claimed fingerprint without duplicating completed children", async () => {
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
const children = [
{
title: "Create the first child once",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
{
title: "Recreate only the missing tail child",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
];
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
});
const claim = await getAcceptedPlanClaim(sourceIssueId);
expect(claim).not.toBeNull();
const [firstChildId, secondChildId] = initial.childIssueIds;
expect(firstChildId).toBeTruthy();
expect(secondChildId).toBeTruthy();
await db.delete(issues).where(eq(issues.id, secondChildId!));
await db
.update(issuePlanDecompositions)
.set({
status: "in_flight",
childIssueIds: [firstChildId!],
completedAt: null,
updatedAt: new Date(),
})
.where(eq(issuePlanDecompositions.id, claim!.id));
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
});
expect(retried.decomposition.status).toBe("completed");
expect(retried.childIssueIds[0]).toBe(firstChildId);
expect(retried.newlyCreatedIssues).toHaveLength(1);
expect(retried.newlyCreatedIssues[0]?.title).toBe("Recreate only the missing tail child");
const childrenRows = await db
.select({ id: issues.id, title: issues.title })
.from(issues)
.where(eq(issues.parentId, sourceIssueId));
expect(childrenRows).toHaveLength(2);
expect(childrenRows.some((row) => row.id === firstChildId)).toBe(true);
expect(childrenRows.map((row) => row.title).sort()).toEqual(children.map((child) => child.title).sort());
});
it("resumes a partial decomposition after reassignment when only actor metadata changes", async () => {
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
const reassignedAgentId = randomUUID();
await db.insert(agents).values({
id: reassignedAgentId,
companyId,
name: "SecondCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const children = [
{
title: "Keep the original child",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
createdByAgentId: assigneeAgentId,
actorAgentId: assigneeAgentId,
},
{
title: "Create only the missing child after reassignment",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
createdByAgentId: assigneeAgentId,
actorAgentId: assigneeAgentId,
},
];
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
});
const claim = await getAcceptedPlanClaim(sourceIssueId);
const [firstChildId, secondChildId] = initial.childIssueIds;
expect(claim).not.toBeNull();
expect(firstChildId).toBeTruthy();
expect(secondChildId).toBeTruthy();
await db.delete(issues).where(eq(issues.id, secondChildId!));
await db
.update(issues)
.set({ assigneeAgentId: reassignedAgentId, updatedAt: new Date() })
.where(eq(issues.id, sourceIssueId));
await db
.update(issuePlanDecompositions)
.set({
status: "in_flight",
childIssueIds: [firstChildId!],
completedAt: null,
ownerAgentId: assigneeAgentId,
updatedAt: new Date(),
})
.where(eq(issuePlanDecompositions.id, claim!.id));
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: children.map((child) => ({
...child,
createdByAgentId: reassignedAgentId,
actorAgentId: reassignedAgentId,
})),
actorAgentId: reassignedAgentId,
});
expect(retried.decomposition.status).toBe("completed");
expect(retried.decomposition.ownerAgentId).toBe(reassignedAgentId);
expect(retried.childIssueIds[0]).toBe(firstChildId);
expect(retried.newlyCreatedIssues).toHaveLength(1);
expect(retried.newlyCreatedIssues[0]?.title).toBe("Create only the missing child after reassignment");
const childrenRows = await db
.select({ id: issues.id, title: issues.title, createdByAgentId: issues.createdByAgentId })
.from(issues)
.where(eq(issues.parentId, sourceIssueId))
.orderBy(asc(issues.createdAt), asc(issues.id));
expect(childrenRows).toHaveLength(2);
expect(childrenRows.map((row) => row.id).sort()).toEqual([...retried.childIssueIds].sort());
expect(childrenRows.find((row) => row.id !== firstChildId)?.createdByAgentId).toBe(reassignedAgentId);
});
it("preserves the existing live claim owner when another actor resumes the same fingerprint", async () => {
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
const competingAgentId = randomUUID();
const liveOwnerRunId = randomUUID();
const competingRunId = randomUUID();
await db.insert(agents).values({
id: competingAgentId,
companyId,
name: "SecondCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
{
id: liveOwnerRunId,
companyId,
agentId: assigneeAgentId,
status: "running",
invocationSource: "manual",
},
{
id: competingRunId,
companyId,
agentId: competingAgentId,
status: "running",
invocationSource: "manual",
},
]);
const children = [
{
title: "Keep the first created child",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
{
title: "Create the missing second child",
status: "todo" as const,
workMode: "standard" as const,
priority: "medium" as const,
},
];
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: assigneeAgentId,
actorRunId: liveOwnerRunId,
});
const [firstChildId, secondChildId] = initial.childIssueIds;
const claim = await getAcceptedPlanClaim(sourceIssueId);
await db.delete(issues).where(eq(issues.id, secondChildId!));
await db
.update(issuePlanDecompositions)
.set({
status: "in_flight",
childIssueIds: [firstChildId!],
completedAt: null,
ownerAgentId: assigneeAgentId,
ownerRunId: liveOwnerRunId,
updatedAt: new Date(),
})
.where(eq(issuePlanDecompositions.id, claim!.id));
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children,
actorAgentId: competingAgentId,
actorRunId: competingRunId,
});
expect(retried.decomposition.status).toBe("completed");
expect(retried.decomposition.ownerAgentId).toBe(assigneeAgentId);
expect(retried.decomposition.ownerRunId).toBe(liveOwnerRunId);
});
it("lists persisted decompositions with child issue summaries", async () => {
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
const initial = await svc.listAcceptedPlanDecompositions(sourceIssueId);
expect(initial).toEqual([]);
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: [
{
title: "Surface decomposition status in operator UI",
status: "todo",
workMode: "standard",
priority: "medium",
},
{
title: "Add regression coverage",
status: "todo",
workMode: "standard",
priority: "medium",
},
],
actorAgentId: assigneeAgentId,
});
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssueId);
expect(decompositions).toHaveLength(1);
const [record] = decompositions;
expect(record?.status).toBe("completed");
expect(record?.acceptedPlanRevisionId).toBe(acceptedPlanRevisionId);
expect(record?.acceptedPlanRevisionNumber).toBeTypeOf("number");
expect(record?.childIssues.map((child) => child.id).sort()).toEqual(
[...result.childIssueIds].sort(),
);
expect(record).not.toHaveProperty("requestedChildren");
expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true);
});
});
describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adoption", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-checkout-owner-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedOwnershipIssue(params: {
checkoutStatus: "running" | "failed" | "timed_out";
actorRunStatus?: "running" | "failed" | "timed_out" | "succeeded";
assigneeMatchesActor?: boolean;
}) {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const actorAgentId = params.assigneeMatchesActor === false ? randomUUID() : assigneeAgentId;
const staleRunId = randomUUID();
const actorRunId = randomUUID();
const issueId = randomUUID();
const actorRunStatus = params.actorRunStatus ?? "running";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const agentRows = [
{
id: assigneeAgentId,
companyId,
name: "Assignee",
role: "engineer" as const,
status: "active" as const,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
];
if (actorAgentId !== assigneeAgentId) {
agentRows.push({
id: actorAgentId,
companyId,
name: "Actor",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
}
await db.insert(agents).values(agentRows);
await db.insert(heartbeatRuns).values([
{
id: staleRunId,
companyId,
agentId: assigneeAgentId,
status: params.checkoutStatus,
invocationSource: "manual",
finishedAt: params.checkoutStatus === "running" ? null : new Date(),
},
{
id: actorRunId,
companyId,
agentId: actorAgentId,
status: actorRunStatus,
invocationSource: "manual",
startedAt: actorRunStatus === "running" ? new Date() : null,
finishedAt: actorRunStatus === "running" ? null : new Date(),
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Checkout owner recovery",
status: "in_progress",
priority: "high",
assigneeAgentId,
checkoutRunId: staleRunId,
executionRunId: staleRunId,
executionLockedAt: new Date(),
executionAgentNameKey: "assignee",
});
return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId };
}
it("lets the current assignee adopt a stale terminal checkout owner", async () => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
expect(ownership.executionRunId).toBe(seeded.actorRunId);
expect(ownership.adoptedFromRunId).toBeNull();
});
it("treats timed_out checkout owners as stale and recoverable", async () => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "timed_out" });
const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
expect(ownership.adoptedFromRunId).toBeNull();
});
it("does not allow non-assignees to adopt stale checkout ownership", async () => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", assigneeMatchesActor: false });
await expect(
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
).rejects.toMatchObject({ status: 409 });
});
it("keeps live checkout owners protected with a 409 conflict", async () => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "running" });
await expect(
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
).rejects.toMatchObject({ status: 409 });
});
it("does not let terminal actor runs adopt stale checkout ownership", async () => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", actorRunStatus: "succeeded" });
await expect(
svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
).rejects.toMatchObject({ status: 409 });
const row = await db
.select({
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, seeded.issueId))
.then((rows) => rows[0]);
expect(row).toEqual({
checkoutRunId: null,
executionRunId: null,
});
});
it("adopts unowned checkout after a concurrent stale-checkout clear wins the lock race", async () => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
await db
.update(issues)
.set({
executionRunId: seeded.actorRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, seeded.issueId));
const rowLocked = deferred<void>();
const clearCanCommit = deferred<void>();
const concurrentClear = db.transaction(async (tx) => {
await tx.execute(
sql`select ${issues.id} from ${issues} where ${issues.id} = ${seeded.issueId} for update`,
);
rowLocked.resolve();
await clearCanCommit.promise;
await tx
.update(issues)
.set({
checkoutRunId: null,
executionRunId: seeded.actorRunId,
executionLockedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(issues.id, seeded.issueId));
});
await rowLocked.promise;
const ownershipPromise = svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
await new Promise((resolve) => setTimeout(resolve, 10));
clearCanCommit.resolve();
await concurrentClear;
const ownership = await ownershipPromise;
expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
expect(ownership.executionRunId).toBe(seeded.actorRunId);
expect(ownership.adoptedFromRunId).toBeNull();
const row = await db
.select({
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, seeded.issueId))
.then((rows) => rows[0]);
expect(row).toEqual({
checkoutRunId: seeded.actorRunId,
executionRunId: seeded.actorRunId,
});
});
});
describeEmbeddedPostgres("issueService.addComment createdByRunId", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let singleConnectionDb!: ReturnType<typeof createDb>;
let singleConnectionSvc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
let agentId!: string;
let issueId!: string;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-comment-runid-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
singleConnectionDb = createDb(tempDb.connectionString, { maxConnections: 1 });
singleConnectionSvc = issueService(singleConnectionDb);
companyId = randomUUID();
agentId = randomUUID();
issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "TestAgent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Test issue",
status: "todo",
priority: "medium",
});
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
async function createdByRunIdFor(commentId: string) {
return db
.select({ createdByRunId: issueComments.createdByRunId })
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => rows[0]?.createdByRunId ?? null);
}
it("keeps addComment transaction reads on a single pooled connection", async () => {
const comment = await singleConnectionDb.transaction(async (tx) =>
singleConnectionSvc.addComment(
issueId,
"transaction-safe comment",
{},
{ authorType: "system" },
tx,
),
);
expect(comment.body).toBe("transaction-safe comment");
});
it("nulls out a non-UUID x-paperclip-run-id instead of 500-ing", async () => {
const comment = await svc.addComment(issueId, "hello from a synthetic run id", {
runId: "client-request-abc123",
});
expect(comment.id).toBeTruthy();
expect(await createdByRunIdFor(comment.id)).toBeNull();
});
it("nulls out a UUID runId absent from heartbeat_runs instead of 500-ing", async () => {
const comment = await svc.addComment(issueId, "hello from a stale run", {
runId: randomUUID(),
});
expect(comment.id).toBeTruthy();
expect(await createdByRunIdFor(comment.id)).toBeNull();
});
it("preserves a valid runId that exists in heartbeat_runs for the company", async () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
});
const comment = await svc.addComment(issueId, "hello from a live run", { runId });
expect(await createdByRunIdFor(comment.id)).toBe(runId);
});
it("deduplicates concurrent identical comments from the same run", async () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
});
const [first, second] = await Promise.all([
svc.addComment(issueId, "one durable result", { agentId, runId }),
svc.addComment(issueId, "one durable result", { agentId, runId }),
]);
expect(second.id).toBe(first.id);
const duplicates = await db
.select({ id: issueComments.id })
.from(issueComments)
.where(eq(issueComments.createdByRunId, runId));
expect(duplicates).toHaveLength(1);
});
});