Files
PaperClipAI/server/src/__tests__/issues-service.test.ts
T
Devin FoleyandPaperclip 6c198d616e fix(issues): preserve human handoffs from the active agent run (#16082)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks have one owner, and the server records assignment changes.
> - An agent can finish its work by handing the task to a human for
review.
> - The assignment route stops the active run before it saves that
handoff.
> - When the caller is that run, a slow workspace restore can exceed the
stop timeout and prevent the assignment from saving.
> - Recovery can then mark the task Blocked while its comment says the
handoff succeeded.
> - This pull request lets the authenticated legacy run save a human
handoff and finish its turn normally.
> - Other actors and transfers to executable agents keep the verified
stop requirement.

## Linked Issues or Issue Description

Refs #13730. Related: #13782, #14700, and #15094.

**What happened?**

An agent posted its review handoff, then sent `PATCH /api/issues/{id}`
to assign the task to its human creator with status `in_review`. The
route cancelled that same run before saving the assignment. Workspace
restore took 66 seconds. The stop limit is 60 seconds. The assignment
never committed. Recovery marked the task Blocked, and it stayed
assigned to the agent.

**Expected behavior**

The authenticated active legacy run can hand its own task to a human and
finish normally. The human assignment and requested review status must
save without waiting for workspace restore. Native runs, board
reassignment, agent API keys, and transfers to another agent still
require a verified stop.

**Steps to reproduce**

1. Run a legacy agent on its assigned task with a signed run token.
2. Send a task PATCH from that run with a human `assigneeUserId`, a null
`assigneeAgentId`, and status `in_review`.
3. Make stopping the sandbox take more than 60 seconds. Before this fix,
the handoff fails before the task update. With this fix, the update
saves and the run finishes normally.

**Paperclip version or commit**

Reproduced in hosted execution. The patch is based on master at
`56a0bf0d492`.

**Deployment mode**

Hosted authenticated server with a legacy agent in a remote sandbox. The
same route serves local deployments.

PR #13782 proposes a broader exemption for all self-handoffs. This
focused change limits the exemption to human recipients and legacy runs.
It checks the exact active run, agent, company, and task, and it retains
the stop requirement before an executable successor can take ownership.
PRs #14700 and #15094 address later wake and recovery behavior after a
reassignment stop.

## What Changed

- Skip the reassignment cancellation only for an authenticated legacy
run handing its own task to a human.
- Reject later mutations from the outgoing run against its human-owned
task, both in route authorization and under the update transaction
locks.
- Keep assignment permission checks, goal control, and queued human
response protection.
- Add route regressions for slow stops, active-run lookup, actor and
runtime boundaries, recipient types, permission denials, and follow-up
edits, completion, and reassignment.
- Update the real HTTP low-trust handoff test to prove the run stays
alive, its late edit fails, and the human wake receipt remains
invalidated.
- Add Postgres tests for saved assignment, former-owner rejection, and
review state after the outgoing run succeeds or fails.
- Document the handoff rule and update the task feature and agent access
maps.

## Verification

- 328 relevant tests passed across the route, issue service, low-trust
HTTP, and legacy recovery suites. The route suite passed all 160 tests,
and all 13 low-trust HTTP tests passed after updating the obsolete
cancellation expectation.
- Removing the production guard makes both slow-shutdown handoff
regressions fail with HTTP 500.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `node scripts/check-maps.mjs` passed.
- `git diff --check` passed.
- All 54 current-head checks passed; 2 optional Storybook checks were
skipped. The CI lanes cover the full Vitest suite, typecheck/build,
Runner checks, and product E2E checks.
- Apex reviewed `c68fbfb356c3d80eb7136babb49940709a3c3459` at 5/5 with
no findings; both earlier review threads are resolved.
- The complete test lanes passed in CI. The duplicate local full `pnpm
test:run` was stopped after CI completed; it did not finish locally. All
328 focused local tests and full local typecheck/build passed.

## Risks

- The outgoing legacy turn stays alive after the human takes ownership.
Route and transactional ownership checks reject its later mutations of
the human-owned task. Read and comment access follows normal policy.
Goal control still runs before the handoff.
- This exemption does not apply to native runs or another agent as
recipient. Those paths still stop the old execution before assignment.
- This fix prevents future failed handoffs. It does not infer or replay
old assignment requests.
- There is no schema change or API shape change.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The session does not expose a more specific runtime model ID
or context window size.

## 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-12 20:34:28 -07:00

7445 lines
228 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,
issueRecoveryActions,
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 { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.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 }),
]);
});
it("ignores historical reader-attributed recovery cleanup without hiding explicit recovery decisions", async () => {
const companyId = await seedAssignableAgentCompany();
const userId = "board-user";
const old = new Date("2026-03-26T10:00:00Z");
const recent = new Date("2026-03-26T15:00:00Z");
const variants = [
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "read_projection" }, counts: false },
{ id: randomUUID(), details: null, counts: true },
{ id: randomUUID(), details: { outcome: "restored" }, counts: true },
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "issue_update" }, counts: true },
];
await db.insert(issues).values(variants.map(({ id }) => ({
id, companyId, title: "Recovery audit", status: "done", updatedAt: old,
})));
await db.insert(activityLog).values(variants.map(({ id, details }) => ({
companyId, actorType: "user", actorId: userId, action: "issue.recovery_action_resolved",
entityType: "issue", entityId: id, details, createdAt: recent,
})));
await db.insert(issueComments).values(variants.map(({ id }) => ({
companyId, issueId: id, authorUserId: "other-user", body: "An older update", createdAt: old,
})));
const expectedIds = variants.filter(v => v.counts).map(v => v.id).sort();
expect((await svc.list(companyId, { touchedByUserId: userId })).map(v => v.id).sort()).toEqual(expectedIds);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(expectedIds.length);
const rows = await svc.list(companyId, {});
for (const variant of variants) {
expect(rows.find(v => v.id === variant.id)?.lastActivityAt).toEqual(variant.counts ? recent : old);
}
// Audit evidence is preserved, including the wrongly attributed old row.
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(variants.length);
});
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("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("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("preserves projectless organization when an update adds workspace linkage", 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).toBeNull();
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(companyMemberships);
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.each(["succeeded", "failed"])("preserves a human handoff when the outgoing legacy run later %s", async status => {
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
const original = await svc.getById(seeded.issueId);
await db.insert(companyMemberships).values({
companyId: original!.companyId,
principalType: "user",
principalId: "handoff-reviewer",
status: "active",
});
await db.update(heartbeatRuns).set({
contextSnapshot: { issueId: seeded.issueId },
}).where(eq(heartbeatRuns.id, seeded.actorRunId));
await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
const handoff = await svc.update(seeded.issueId, {
assigneeAgentId: null,
assigneeUserId: "handoff-reviewer",
status: "in_review",
actorAgentId: seeded.actorAgentId,
actorRunId: seeded.actorRunId,
});
expect(handoff).toMatchObject({
assigneeAgentId: null,
assigneeUserId: "handoff-reviewer",
status: "in_review",
checkoutRunId: null,
executionRunId: null,
});
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.actorRunId));
expect(run!.status).toBe("running");
await expect(svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId))
.rejects.toMatchObject({ status: 409 });
await expect(svc.update(seeded.issueId, {
title: "Late outgoing edit",
actorAgentId: seeded.actorAgentId,
actorRunId: seeded.actorRunId,
})).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } });
await terminalizeLegacyExecution({
db,
run: run!,
status,
fromStatuses: ["running"],
reconcileIfNeeded: true,
patch: { finishedAt: new Date(), ...(status === "failed" ? { errorCode: "process_lost" } : {}) },
});
await settleUnrecoverableExecutions(db);
await expect(svc.update(seeded.issueId, {
status: "done",
actorAgentId: seeded.actorAgentId,
actorRunId: seeded.actorRunId,
})).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } });
expect(await svc.getById(seeded.issueId)).toMatchObject({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "handoff-reviewer",
});
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId)))
.toHaveLength(0);
});
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);
});
});