Files
PaperClipAI/server/src/__tests__/issue-comment-reopen-routes.test.ts
T
DottaandPaperclip 4e27a82a42 fix(server): wake approval queue seeding from committed changes (#17510)
## Thinking Path

> - Paperclip manages work and human decisions for AI-agent companies.
> - Automatic decision queues store membership for pending attention
sources.
> - The old attention maintenance scan created that membership as a side
effect.
> - Moving approval retention out of the scan also removed approval
queue discovery.
> - Queue API consumers do not build the attention feed first, so they
can miss pending PR approvals.
> - This pull request gives approval queue discovery its own worker and
commit signals.
> - The worker becomes quiet after it saves every missing membership.

## Linked Issues or Issue Description

Refs #17497. Stacked on #17508, following #17505.

This is a correctness dependency for the approval retention migration in
#17497. Do not treat that migration as complete without this
queue-population path. A real PostgreSQL regression found no PR queue
when a pending approval had a linked PR work product and the maintenance
feed excluded approvals.

Related: #13364 proposes pruning stale seeded memberships. This PR
preserves existing membership retention and does not implement pruning.
#15149 documents the decision-queue APIs.

## What Changed

- Add an approval queue worker that selects missing PR-queue memberships
from existing durable records. Bound each pass to 100 rows. Continue
past failed rows and revisit them on retry.
- Reuse the queue service's seed writer through a normalized source
contract. Keep queue rules, uniqueness, and audit writes in that
service. Keep feed readers compatible.
- Wake from committed approval and link changes, issue/company
visibility, PR work-product changes and imports, and queue-rule changes.
Reuse the existing transaction boundaries.
- Recheck approval status under a row lock. Serialize queue-rule changes
with the seed writer. Preserve required recovery during drain and
shutdown.
- Register startup reconstruction under the existing maintenance switch.
Leave other attention-source seeding with its current owner until its
own migration.
- Document the complete producer audit and stack dependency. Add a
validated external PostgreSQL option to the existing queue route suite
so it runs on hosts without embedded database support.

## Verification

Current tested revision: `7948136ef8`. This PR targets master after
#17508 merged as `8406047b4e`. Normal ancestry reconciliation preserved
the complete tested integration tree, before the review repairs below.
Only the original 25 seeding paths differ from master, and database
schema/migration metadata is identical.

- **56 cases pass in the three complete affected suites:** 28 approval
seeding, 6 queue routes and 22 task-watchdog cases. Server typecheck and
production build pass.
- Three real PostgreSQL retry regressions failed then passed: new
matching arrivals cannot postpone a recovered failure indefinitely;
successful partial progress resets old failure penalties; an empty
cursor rollover does not reset outage backoff. Exact one- and two-second
retry assertions, eventual complete recovery and existing
capped-backoff/company isolation tests pass.
- Three genuinely empty external databases failed before setup repair
because tables were missing. Each suite now applies migrations itself.
All 55 cases passed on a second set of fresh databases; adding the final
rollover guard then passed all 56 cases using those now-migrated
fixtures. No production migration or table is added.
- The initial expanded fresh run passed 54 cases but failed a cleanup
expectation that omitted a required cursor rollover pass. The fixture
now permits that explicit continuation while preserving exact deadline
and complete-membership assertions. All failure records remain in
`doc/idle-approval-queue-seeding.md`.
- The prior integrated revision `4e725e2055` passed **522 cases in
fifteen complete producer/consumer suites**, plus types/build. Its
unchanged consumer code and historical deadlock, cross-company
amplification, backoff, gateway integration and setup evidence remain
documented; those counts are not relabeled as current acceptance.

Commands: server Vitest for approval queue seeding, queue routes and
task watchdogs; server typecheck/build. Fresh exact-head
Greptile/Ponytail reviews and all CI checks remain required. No full
local repository-suite, deployed suspension or cost claim is made.

## Risks

- Signals cover the supported in-process service writers. Independent
SQL writers and multiple server replicas need a separate notification
contract.
- The source predicate must stay aligned with unscoped attention
maintenance. The first linked issue, company scope, pending status, and
execution visibility are tested. A pending approval itself keeps its
linked external conversation active.
- Existing enabled seed rules can re-add manually removed matching
memberships. This preserves the old scan's behavior. Completed or
archived membership is not pruned.
- Queue seeding and retention are separate owners. Other attention
sources still need their own event-driven discovery before the aggregate
scan can be retired.
- This PR does not extend Cloud wake coverage, relax sleep admission, or
claim measured host/database suspension.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact
serving model identifier and context window were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-27 20:33:01 -05:00

3911 lines
135 KiB
TypeScript

import express from "express";
import request from "supertest";
import { getTableName } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError, unprocessable } from "../errors.js";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
vi.mock("../services/fast-responses.js", () => ({
enqueueFastResponse: vi.fn(async () => undefined),
}));
vi.mock("../services/execution-blocker.js", () => ({
getExecutionBlocker: vi.fn(async () => null),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdForUpdate: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(),
addComment: vi.fn(),
getDependencyReadiness: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
findMentionedAgents: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
getRelationSummaries: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
list: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockTxInsertValues = vi.hoisted(() => vi.fn(async () => undefined));
const mockTxInsert = vi.hoisted(() =>
vi.fn(() => ({ values: mockTxInsertValues })),
);
const mockTx = vi.hoisted(() => ({
insert: mockTxInsert,
}));
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
const mockDbSelectWhere = vi.hoisted(() =>
vi.fn(() => ({
orderBy: mockDbSelectOrderBy,
then: (
onFulfilled: (rows: unknown[]) => unknown,
onRejected?: (reason: unknown) => unknown,
) => Promise.resolve([]).then(onFulfilled, onRejected),
})),
);
const mockDbSelectFrom = vi.hoisted(() =>
vi.fn((_table: unknown) => ({ where: mockDbSelectWhere })),
);
const mockDbSelect = vi.hoisted(() =>
vi.fn(() => ({ from: mockDbSelectFrom })),
);
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) =>
fn(mockTx),
),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
})),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expirePendingInteractionsForTerminalIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
listForIssue: vi.fn(async () => []),
}));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
}));
const mockIssueTreeControlService = vi.hoisted(() => ({
getActivePauseHoldGate: vi.fn(async () => null),
}));
const mockExternalObjectService = vi.hoisted(() => ({
syncCommentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn());
const mockCrossIssueInfluenceLimitError = vi.hoisted(() => vi.fn());
const mockCrossIssueInfluenceRunContextError = vi.hoisted(() => vi.fn());
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../services/queued-interaction-response.js", () => ({
hasQueuedInteractionResponse: vi.fn(async () => false),
}));
vi.mock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.mock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.mock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.mock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.mock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.mock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.mock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),
}),
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({
remapOpenThreadsForDocument: async () => [],
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueRecoveryActionService: () => mockIssueRecoveryActionService,
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
issueTreeControlService: () => mockIssueTreeControlService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
vi.mock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.mock("../services/cross-issue-influence-limit.js", () => ({
observeCrossIssueInfluence: mockObserveCrossIssueInfluence,
crossIssueInfluenceLimitError: mockCrossIssueInfluenceLimitError,
crossIssueInfluenceRunContextError: mockCrossIssueInfluenceRunContextError,
}));
function createApp() {
const app = express();
app.use(express.json());
return app;
}
async function installActor(
app: express.Express,
actor?: Record<string, unknown>,
) {
app.use((req, _res, next) => {
(req as any).actor = actor ?? {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
async function normalizePolicy(input: {
stages: Array<{
id: string;
type: "review" | "approval";
participants: Array<
{ type: "agent"; agentId: string } | { type: "user"; userId: string }
>;
}>;
}) {
return normalizeIssueExecutionPolicy(input);
}
function makeIssue(
status:
| "backlog"
| "todo"
| "done"
| "blocked"
| "cancelled"
| "in_progress"
| "in_review",
) {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
status,
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-580",
title: "Comment reopen default",
};
}
function makeIssueUpdateReceipt(
existing: ReturnType<typeof makeIssue>,
patch: Record<string, unknown>,
) {
const fields = Object.fromEntries(
Object.entries(patch).filter(
([key]) => key !== "actorAgentId" && key !== "actorUserId",
),
);
const changes = Object.fromEntries(
Object.entries(fields)
.filter(
([key, value]) =>
!Object.is(existing[key as keyof typeof existing], value),
)
.map(([key, value]) => [
key,
{ from: existing[key as keyof typeof existing], to: value },
]),
);
return { ...existing, ...fields, changes };
}
function agentActor(agentId = "22222222-2222-4222-8222-222222222222") {
return {
type: "agent",
agentId,
companyId: "company-1",
source: "agent_key",
runId: "run-1",
};
}
async function waitForWakeup(assertion: () => void) {
await vi.waitFor(assertion);
}
function createAgentReviewPolicy(reviewerAgentId: string) {
return normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [{ type: "agent", agentId: reviewerAgentId }],
},
],
});
}
function createReviewIssue(policy: NonNullable<Awaited<ReturnType<typeof normalizePolicy>>>, reviewerAgentId: string) {
return {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: reviewerAgentId,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
}
function createScheduledRetry() {
return {
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "22222222-2222-4222-8222-222222222222",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
error: null,
errorCode: null,
};
}
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockReset();
mockIssueService.getByIdForUpdate.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.update.mockReset();
mockIssueService.addComment.mockReset();
mockIssueService.getDependencyReadiness.mockReset();
mockIssueService.getCurrentScheduledRetry.mockReset();
mockIssueService.findMentionedAgents.mockReset();
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.hasPermission.mockReset();
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.reportRunActivity.mockReset();
mockHeartbeatService.getRun.mockReset();
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.cancelRun.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
mockAgentService.resolveByReference.mockReset();
mockLogActivity.mockReset();
mockFeedbackService.listIssueVotesForUser.mockReset();
mockFeedbackService.saveIssueVote.mockReset();
mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset();
mockRoutineService.syncRunStatusForIssue.mockReset();
mockIssueThreadInteractionService.listForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockExternalObjectService.syncCommentSafely.mockReset();
mockExternalObjectService.syncIssueSafely.mockReset();
mockObserveCrossIssueInfluence.mockReset();
mockCrossIssueInfluenceLimitError.mockReset();
mockCrossIssueInfluenceRunContextError.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
mockDbSelectFrom.mockReset();
mockDbSelectWhere.mockReset();
mockDbSelectOrderBy.mockReset();
mockDb.transaction.mockReset();
mockTxInsertValues.mockResolvedValue(undefined);
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
mockDbSelectOrderBy.mockResolvedValue([]);
mockDbSelectWhere.mockImplementation(() => ({
orderBy: mockDbSelectOrderBy,
then: (
onFulfilled: (rows: unknown[]) => unknown,
onRejected?: (reason: unknown) => unknown,
) => Promise.resolve([]).then(onFulfilled, onRejected),
}));
mockDbSelectFrom.mockImplementation((table) => {
const name = getTableName(table as Parameters<typeof getTableName>[0]);
if (name === "case_handoffs") return { where: vi.fn(() => ({
orderBy: vi.fn(() => ({ limit: vi.fn(async () => []) })),
})) };
if (name === "heartbeat_runs") return { where: vi.fn(() => {
const query = mockDbSelectWhere();
return { ...query, limit: vi.fn((count: number) => Promise.resolve(query).then(rows => rows.slice(0, count))) };
}) };
return { where: mockDbSelectWhere };
});
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDb.transaction.mockImplementation(
async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx),
);
mockIssueService.getByIdForUpdate.mockImplementation(async () =>
mockIssueService.getById(),
);
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
mockExternalObjectService.syncIssueSafely.mockResolvedValue(undefined);
mockObserveCrossIssueInfluence.mockResolvedValue({
allowed: true,
mode: "log_only",
count: 1,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
mockCrossIssueInfluenceLimitError.mockImplementation(
(decision: { count: number; cap: number }) => ({
error: `Cross-issue influence cap exceeded: this run is limited to ${decision.cap} cross-issue comments or updates`,
details: {
code: "cross_issue_influence_cap_exceeded",
count: decision.count,
cap: decision.cap,
},
}),
);
mockCrossIssueInfluenceRunContextError.mockImplementation(
() =>
new HttpError(
403,
"Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained",
{ code: "cross_issue_influence_run_context_required" },
),
);
mockLogActivity.mockResolvedValue(undefined);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue(null);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "hello",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: null,
authorUserId: "local-board",
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getDependencyReadiness.mockResolvedValue({
issueId: "11111111-1111-4111-8111-111111111111",
blockerIssueIds: [],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
allBlockersDone: true,
isDependencyReady: true,
});
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(
null,
);
mockIssueService.assertCheckoutOwner.mockResolvedValue({
adoptedFromRunId: null,
});
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(
async (input: { action?: string }) => {
const allowed = input.action !== "tasks:manage_active_checkouts";
return {
allowed,
action: input.action,
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
explanation: allowed
? "Allowed by test grant."
: "Missing active checkout override.",
};
},
);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.list.mockResolvedValue([
{
id: "22222222-2222-4222-8222-222222222222",
reportsTo: null,
permissions: { canCreateAgents: false },
},
{
id: "44444444-4444-4444-8444-444444444444",
reportsTo: null,
permissions: { canCreateAgents: false },
},
]);
mockAgentService.resolveByReference.mockImplementation(
async (_companyId: string, reference: string) => {
if (reference === "ambiguous-codex") {
return { ambiguous: true, agent: null };
}
if (reference === "missing-codex") {
return { ambiguous: false, agent: null };
}
if (reference === "codexcoder") {
return {
ambiguous: false,
agent: { id: "33333333-3333-4333-8333-333333333333" },
};
}
return {
ambiguous: false,
agent: { id: reference },
};
},
);
});
it("treats reopen=true as a no-op when the issue is already open", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("todo"),
...patch,
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
comment: "hello",
reopen: true,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(200);
expect(res.body.assigneeAgentId).toBe(
"33333333-3333-4333-8333-333333333333",
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.not.objectContaining({ reopened: true }),
}),
);
});
it("binds explicit attachments in the same transaction as a PATCH comment reassignment", async () => {
const issue = makeIssue("todo");
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(await installActor(createApp()))
.patch(`/api/issues/${issue.id}`)
.send({
comment: "Inspect the file",
commentClientRequestId: "77777777-7777-4777-8777-777777777777",
attachmentIds: [id],
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(200);
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledWith(
issue.id,
expect.not.objectContaining({ attachmentIds: expect.anything() }),
mockTx,
);
expect(mockIssueService.addComment).toHaveBeenCalledTimes(1);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issue.id,
"Inspect the file",
expect.anything(),
expect.objectContaining({ attachmentIds: [id], clientRequestId: "77777777-7777-4777-8777-777777777777" }),
mockTx,
);
});
it("rejects attachment IDs without a PATCH comment before updating or waking", async () => {
const issue = makeIssue("todo");
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await installActor(createApp()))
.patch(`/api/issues/${issue.id}`)
.send({
attachmentIds: [],
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(400);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not dispatch a wake or fallback comment when transactional attachment binding fails", async () => {
const issue = makeIssue("todo");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
mockIssueService.addComment.mockRejectedValueOnce(
new HttpError(409, "Attachment is already bound"),
);
const res = await request(await installActor(createApp()))
.patch(`/api/issues/${issue.id}`)
.send({
comment: "Inspect",
attachmentIds: ["9af8228f-0be7-45ae-a104-6fbe0af6f1d3"],
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(409);
expect(mockIssueService.addComment).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => {
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
comment: "hello",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
reopened: true,
reopenedFrom: "done",
status: "todo",
}),
}),
);
});
it("resolves assignee shortnames before updating an issue", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("todo"),
...patch,
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", assigneeAgentId: "codexcoder" });
expect(res.status).toBe(200);
expect(mockAgentService.resolveByReference).toHaveBeenCalledWith(
"company-1",
"codexcoder",
);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
}),
);
});
it("rejects ambiguous assignee shortnames", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ assigneeAgentId: "ambiguous-codex" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("ambiguous");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects missing assignee shortnames", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ assigneeAgentId: "missing-codex" });
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("reopens closed issues via the PATCH comment path", async () => {
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
comment: "hello",
reopen: true,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
reopened: true,
reopenedFrom: "done",
status: "todo",
}),
}),
);
});
it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => {
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "hello" });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
reopenedFrom: "done",
}),
}),
),
);
});
it("allows default-open non-assignee POST comments on closed issues without reopening", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "hello",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: "33333333-3333-4333-8333-333333333333",
authorUserId: null,
});
mockAccessService.decide.mockImplementation(
async (input: { action?: string }) => ({
allowed: input.action !== "tasks:manage_active_checkouts",
action: input.action,
reason:
input.action === "issue:comment"
? "allow_visible_issue_write"
: "allow_explicit_grant",
explanation: "Allowed by the shared visible-issue write rule.",
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
source: "agent_key",
runId: "77777777-7777-4777-8777-777777777777",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "hello" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("allows mention-granted non-assignee agent POST comments on closed issues without reopening", async () => {
const mentionedAgentId = "33333333-3333-4333-8333-333333333333";
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "I can answer the mention without reopening.",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: mentionedAgentId,
authorUserId: null,
});
mockAccessService.decide.mockImplementation(
async (input: { action?: string }) => {
const allowed = input.action === "issue:read" || input.action === "issue:comment";
return {
allowed,
action: input.action,
reason: allowed ? "allow_issue_mention_grant" : "deny_missing_grant",
explanation: allowed
? "Allowed by a mention-scoped issue comment grant."
: "Missing permission.",
};
},
);
const res = await request(
await installActor(createApp(), agentActor(mentionedAgentId)),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "I can answer the mention without reopening." });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockAccessService.decide).not.toHaveBeenCalledWith(
expect.objectContaining({ action: "issue:mutate" }),
);
});
it.each([
["resume", { resume: true }],
["reopen", { reopen: true }],
])(
// Mention grants are append-only; explicit lifecycle intent still requires mutation authority.
"denies mention-granted non-assignee agent POST comments on closed issues with %s intent",
async (_name, intent) => {
const mentionedAgentId = "33333333-3333-4333-8333-333333333333";
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "Please continue this closed issue.",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: mentionedAgentId,
authorUserId: null,
});
mockAccessService.decide.mockImplementation(
async (input: { action?: string }) => {
const allowed = input.action === "issue:read" || input.action === "issue:comment";
return {
allowed,
action: input.action,
reason: allowed
? "allow_issue_mention_grant"
: "deny_missing_grant",
explanation: allowed
? "Allowed by a mention-scoped issue comment grant."
: "Missing permission.",
};
},
);
const res = await request(
await installActor(createApp(), agentActor(mentionedAgentId)),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Please continue this closed issue.", ...intent });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details.code).toBe("issue_write_not_visible");
// Plan §6: name the boundary, who can act, and the sanctioned path.
expect(res.body.error).toContain("Issue visibility");
expect(res.body.error).toContain("Who can act:");
expect(res.body.details.sanctionedPath).toContain("child issue");
expect(mockAccessService.decide).toHaveBeenCalledWith(
expect.objectContaining({ action: "issue:comment" }),
);
expect(mockAccessService.decide).toHaveBeenCalledWith(
expect.objectContaining({ action: "issue:mutate" }),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
},
);
// POST self-comment from the assignee agent on a done issue with explicit
// reopen=true is the same log-class signal — the guard must suppress reopen.
it("does not reopen via POST comment+reopen when the assignee agent is the actor on a done issue", async () => {
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "log line",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: assigneeAgentId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: assigneeAgentId,
companyId: "company-1",
runId: "run-self",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "log line", reopen: true });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "todo" }),
);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ reason: "issue_reopened_via_comment" }),
);
});
// Same guard on cancelled status — explicit resume must use `resume: true`,
// a log-class self-comment with `reopen: true` is not a reopen signal.
it("rejects bare agent reopen requests on cancelled issues before writing a comment", async () => {
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
mockIssueService.getById.mockResolvedValue(makeIssue("cancelled"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("cancelled"),
...patch,
}),
);
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "log line",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: assigneeAgentId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: assigneeAgentId,
companyId: "company-1",
runId: "run-self",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "log line", reopen: true });
expect(res.status).toBe(409);
expect(res.body.error).toBe(
"Cancelled issues must be restored through the dedicated restore flow",
);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "todo" }),
);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ reason: "issue_reopened_via_comment" }),
);
});
// The guard must block explicit reopen=true + comment by the assignee agent
// on their own done issue (assignee self-comments are log lines, not reopen
// signals; explicit resume intent is delivered via `resume: true` instead).
it("does not reopen via PATCH comment+reopen when the assignee agent is the actor on a done issue", async () => {
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: assigneeAgentId,
companyId: "company-1",
runId: "run-self",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "log line", reopen: true });
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({ reopened: true }),
}),
);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ reason: "issue_reopened_via_comment" }),
);
});
// The guard compares against the issue's current assignee, not the requested
// one — so an admin agent reassigning a different agent's terminal issue to
// themselves with comment + reopen=true still reopens as today (AC-3).
it("still reopens a done issue via PATCH when a different agent reassigns to self with reopen=true", async () => {
const otherAgentId = "33333333-3333-4333-8333-333333333333";
mockAccessService.decide.mockImplementation(
async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
}),
);
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: otherAgentId,
companyId: "company-1",
runId: "run-other",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
comment: "taking over",
reopen: true,
assigneeAgentId: otherAgentId,
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: otherAgentId,
status: "todo",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
reopened: true,
reopenedFrom: "done",
status: "todo",
}),
}),
);
});
it("moves assigned blocked issues back to todo via POST comments", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("blocked"),
...patch,
}),
);
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please continue" });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "blocked",
mutation: "comment",
}),
contextSnapshot: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
wakeCommentId: "comment-1",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: "blocked",
}),
}),
),
);
});
it("moves in-progress issues with a scheduled retry back to todo via POST human comments", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "retry-run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "I added the missing detail; please continue." });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1", undefined, { expectedStatus: "scheduled_retry" });
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
status: "todo",
scheduledRetrySupersededByComment: true,
scheduledRetryRunId: "retry-run-1",
cancelledScheduledRetryRunId: "retry-run-1",
}),
}),
);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
commentId: "comment-1",
mutation: "comment",
}),
contextSnapshot: expect.objectContaining({
wakeReason: "issue_commented",
source: "issue.comment",
}),
}),
),
);
});
it.each([
["post", "queued"],
["post", "running"],
["patch", "queued"],
["patch", "running"],
] as const)("preserves a %s comment's %s retry execution", async (method, status) => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
...createScheduledRetry(),
status,
});
mockIssueService.update.mockImplementation(async (_id, patch) => ({ ...issue, ...patch }));
const app = await installActor(createApp());
const res = method === "post"
? await request(app).post(`/api/issues/${issue.id}/comments`).send({ body: "Recovery evidence is attached." })
: await request(app).patch(`/api/issues/${issue.id}`).send({ comment: "Recovery evidence is attached." });
expect(res.status).toBe(method === "post" ? 201 : 200);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalledWith(issue.id, expect.objectContaining({ status: "todo" }));
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
issue.assigneeAgentId,
expect.objectContaining({ reason: "issue_commented" }),
));
});
it.each(["post", "patch"] as const)("keeps the task active when a retry is promoted during a %s comment", async method => {
const issue = { ...makeIssue("in_progress"), executionRunId: "retry-run-1" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
mockHeartbeatService.cancelRun.mockResolvedValue({ id: "retry-run-1", status: "running" });
mockIssueService.update.mockImplementation(async (_id, patch) => ({ ...issue, ...patch }));
const app = await installActor(createApp());
const res = method === "post"
? await request(app).post(`/api/issues/${issue.id}/comments`).send({ body: "Additional context for the active run." })
: await request(app).patch(`/api/issues/${issue.id}`).send({ comment: "Additional context for the active run." });
expect(res.status).toBe(method === "post" ? 201 : 200);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1", undefined, { expectedStatus: "scheduled_retry" });
expect(mockIssueService.update).not.toHaveBeenCalledWith(issue.id, expect.objectContaining({ status: "todo" }));
expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "heartbeat.cancelled" }));
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
issue.assigneeAgentId, expect.objectContaining({ reason: "issue_commented" }),
));
});
it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
mockHeartbeatService.cancelRun.mockRejectedValue(
new Error("cancel failed"),
);
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "I added the missing detail; please continue." });
expect(res.status).toBe(500);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1", undefined, { expectedStatus: "scheduled_retry" });
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.updated" }),
);
});
it("keeps ordinary in-progress POST human comments in progress when no scheduled retry exists", async () => {
const issue = makeIssue("in_progress");
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Checking in without retry state." });
expect(res.status).toBe(201);
expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
}),
),
);
});
it("skips the assignee wakeup when the issue is concurrently cancelled while the comment is being written", async () => {
const app = await installActor(createApp());
// First call is the route's pre-insert fetch; second is the wake-decision
// re-fetch. A stale wake decision would use the first (open, assigned)
// snapshot instead of the second (cancelled) one.
mockIssueService.getById
.mockResolvedValueOnce(makeIssue("in_progress"))
.mockResolvedValueOnce(makeIssue("cancelled"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-race-cancel",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "Still working on this?",
});
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Still working on this?" });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalled();
// Give any (incorrect) fire-and-forget wakeup a moment to fire before asserting its absence.
await new Promise((resolve) => setTimeout(resolve, 25));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("wakes the freshly reassigned agent, not the pre-insert snapshot's assignee, when the issue is concurrently reassigned", async () => {
const app = await installActor(createApp());
const reassignedAgentId = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById
.mockResolvedValueOnce(makeIssue("in_progress"))
.mockResolvedValueOnce({
...makeIssue("in_progress"),
assigneeAgentId: reassignedAgentId,
});
mockIssueService.addComment.mockResolvedValue({
id: "comment-race-reassign",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "Status update",
});
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Status update" });
expect(res.status).toBe(201);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
reassignedAgentId,
expect.objectContaining({ reason: "issue_commented" }),
),
);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.anything(),
);
});
it("keeps the comment write successful and falls back to the in-hand snapshot when the wake re-fetch fails", async () => {
const app = await installActor(createApp());
// The comment is already committed before the wake-decision re-fetch runs.
// If that best-effort re-fetch throws, the route must still return 201 for
// the persisted comment (a 5xx would invite a retry that duplicates it) and
// fall back to the in-hand snapshot so a legitimate wake isn't dropped.
mockIssueService.getById
.mockResolvedValueOnce(makeIssue("in_progress"))
.mockRejectedValueOnce(new Error("transient read failure"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-refetch-fail",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "Still here?",
});
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Still here?" });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalled();
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({ reason: "issue_commented" }),
),
);
});
it("passes validated comment presentation fields to trusted board comment writes", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
rows: [
{
type: "key_value",
label: "Cause",
value: "successful_run_missing_state",
},
],
},
],
},
createdAt: new Date(),
updatedAt: new Date(),
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
const metadata = {
version: 1,
sections: [
{
rows: [
{
type: "key_value",
label: "Cause",
value: "successful_run_missing_state",
},
],
},
],
};
const presentation = { kind: "system_notice", tone: "warning" };
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
});
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Paperclip needs a disposition before this issue can continue.",
{
agentId: undefined,
userId: "local-board",
runId: null,
onBehalfOfUserId: undefined,
},
{
attachmentIds: undefined,
authorType: "user",
authorizationReason: "allow_board_actor",
clientRequestId: undefined,
mirrorToSlack: true,
presentation: {
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
},
metadata,
sourceTrust: null,
},
mockTx,
);
});
it("rejects structured comment presentation fields from agent-authenticated writes", async () => {
const app = await installActor(createApp(), agentActor());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Hidden details",
presentation: { kind: "system_notice", tone: "warning" },
metadata: {
version: 1,
sections: [
{
rows: [
{
type: "key_value",
label: "Cause",
value: "covert_channel_attempt",
},
],
},
],
},
});
expect(res.status).toBe(403);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("derives compact presentation for comments from source-scoped recovery runs", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("in_progress"));
mockDbSelectWhere.mockImplementation(() => ({
then: (
onFulfilled: (rows: unknown[]) => unknown,
onRejected?: (reason: unknown) => unknown,
) =>
Promise.resolve([
{
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
contextSnapshot: {
wakeReason: "source_scoped_recovery_action",
recoveryCause: "process_lost",
},
},
]).then(onFulfilled, onRejected),
}));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Recovered the execution path.\nHanded back to the original owner.",
});
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Recovered the execution path.\nHanded back to the original owner.",
{
agentId: "22222222-2222-4222-8222-222222222222",
userId: undefined,
runId: "run-1",
onBehalfOfUserId: null,
},
expect.objectContaining({
attachmentIds: undefined,
authorType: "agent",
presentation: {
kind: "system_notice",
tone: "info",
title: "Recovered the execution path.",
detailsDefaultOpen: false,
density: "compact",
},
}),
mockDb,
);
});
it("leaves normal agent comments without derived presentation", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("in_progress"));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Normal work update." });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Normal work update.",
{
agentId: "22222222-2222-4222-8222-222222222222",
userId: undefined,
runId: "run-1",
onBehalfOfUserId: null,
},
expect.objectContaining({ attachmentIds: undefined, presentation: null }),
mockDb,
);
});
it("keeps successful-run missing-state recovery comments fully visible", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("in_progress"));
mockDbSelectWhere.mockImplementation(() => ({
then: (
onFulfilled: (rows: unknown[]) => unknown,
onRejected?: (reason: unknown) => unknown,
) =>
Promise.resolve([
{
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
contextSnapshot: {
wakeReason: "source_scoped_recovery_action",
recoveryCause: "successful_run_missing_state",
},
},
]).then(onFulfilled, onRejected),
}));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "The run completed; here is the required summary." });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"The run completed; here is the required summary.",
{
agentId: "22222222-2222-4222-8222-222222222222",
userId: undefined,
runId: "run-1",
onBehalfOfUserId: null,
},
expect.objectContaining({ attachmentIds: undefined, presentation: null }),
mockDb,
);
});
it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Invalid metadata",
metadata: { version: 1, arbitrary: true },
});
expect(res.status).toBe(400);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("does not move dependency-blocked issues to todo via POST comments", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getDependencyReadiness.mockResolvedValue({
issueId: "11111111-1111-4111-8111-111111111111",
blockerIssueIds: ["33333333-3333-4333-8333-333333333333"],
unresolvedBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"],
unresolvedBlockerCount: 1,
allBlockersDone: false,
isDependencyReady: false,
});
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "what is happening?" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
commentId: "comment-1",
mutation: "comment",
}),
contextSnapshot: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
wakeCommentId: "comment-1",
wakeReason: "issue_commented",
}),
}),
),
);
});
it("does not implicitly reopen a blocked issue via PATCH when the same request wires blockers", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getRelationSummaries.mockResolvedValue({
blockedBy: [],
blocks: [],
});
mockIssueService.getDependencyReadiness.mockResolvedValue({
issueId: "11111111-1111-4111-8111-111111111111",
blockerIssueIds: [],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
allBlockersDone: true,
isDependencyReady: true,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("blocked"),
...patch,
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
blockedByIssueIds: ["33333333-3333-4333-8333-333333333333"],
comment: "wired the dependency this issue is waiting on",
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
const patch = mockIssueService.update.mock.calls[0][1] as Record<
string,
unknown
>;
expect(patch.status).toBeUndefined();
expect(patch.blockedByIssueIds).toEqual([
"33333333-3333-4333-8333-333333333333",
]);
});
it("still implicitly reopens a blocked issue via PATCH when the same request clears blockers", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getRelationSummaries.mockResolvedValue({
blockedBy: [],
blocks: [],
});
mockIssueService.getDependencyReadiness.mockResolvedValue({
issueId: "11111111-1111-4111-8111-111111111111",
blockerIssueIds: [],
unresolvedBlockerIssueIds: [],
unresolvedBlockerCount: 0,
allBlockersDone: true,
isDependencyReady: true,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("blocked"),
...patch,
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
blockedByIssueIds: [],
comment: "nothing left to wait on, please continue",
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
const patch = mockIssueService.update.mock.calls[0][1] as Record<
string,
unknown
>;
expect(patch.status).toBe("todo");
});
it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => {
mockIssueService.getById.mockResolvedValue({
...makeIssue("done"),
assigneeAgentId: null,
assigneeUserId: "local-board",
});
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "hello" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("does not implicitly reopen done issues via POST comments when the comment runId matches the issue's checkout run", async () => {
mockIssueService.getById.mockResolvedValue({
...makeIssue("done"),
checkoutRunId: "run-same-as-actor",
executionRunId: null,
});
const res = await request(
await installActor(createApp(), {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
runId: "run-same-as-actor",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Done — final note from the run that owns the issue" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "todo" }),
);
});
it("does not implicitly reopen done issues via POST comments when the comment runId matches the issue's execution run", async () => {
mockIssueService.getById.mockResolvedValue({
...makeIssue("done"),
checkoutRunId: null,
executionRunId: "run-same-as-actor",
});
const res = await request(
await installActor(createApp(), {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
runId: "run-same-as-actor",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Done — note from the still-active execution run" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "todo" }),
);
});
it("still implicitly reopens done issues via POST comments when the comment runId differs from the issue's owning run", async () => {
mockIssueService.getById.mockResolvedValue({
...makeIssue("done"),
checkoutRunId: "run-owning",
executionRunId: "run-owning",
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}),
);
const res = await request(
await installActor(createApp(), {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
runId: "run-different",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Real human follow-up — please reopen" });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
});
it("does not implicitly reopen done issues via the PATCH comment path when actor runId matches the issue's checkout run", async () => {
const issue = {
...makeIssue("done"),
checkoutRunId: "run-same-as-actor",
executionRunId: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
}),
);
const res = await request(
await installActor(createApp(), {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
runId: "run-same-as-actor",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Done — final note from the run that owns the issue" });
expect(res.status).toBe(200);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "todo" }),
);
});
it("moves assigned blocked issues back to todo via the PATCH comment path", async () => {
const issue = makeIssue("blocked");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "please continue" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "blocked",
mutation: "comment",
}),
}),
),
);
});
it("moves in-progress issues with a scheduled retry back to todo via the PATCH comment path", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "retry-run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Retry window is over; please continue." });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1", undefined, { expectedStatus: "scheduled_retry" });
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
commentId: "comment-1",
mutation: "comment",
}),
}),
),
);
});
it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "retry-run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
mockHeartbeatService.cancelRun.mockRejectedValue(
new Error("cancel failed"),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Retry window is over; please continue." });
expect(res.status).toBe(500);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1", undefined, { expectedStatus: "scheduled_retry" });
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.updated" }),
);
});
it("allows default-open non-assignee PATCH comments on closed issues without reopening", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
body: "hello",
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: "33333333-3333-4333-8333-333333333333",
authorUserId: null,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
source: "agent_key",
runId: "88888888-8888-4888-8888-888888888888",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not move dependency-blocked issues to todo via the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getDependencyReadiness.mockResolvedValue({
issueId: "11111111-1111-4111-8111-111111111111",
blockerIssueIds: ["33333333-3333-4333-8333-333333333333"],
unresolvedBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"],
unresolvedBlockerCount: 1,
allBlockersDone: false,
isDependencyReady: false,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("blocked"),
...patch,
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "what is happening?" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "todo" }),
);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
commentId: "comment-1",
mutation: "comment",
}),
}),
),
);
});
it("wakes the assignee when an assigned blocked issue moves back to todo", async () => {
const issue = makeIssue("blocked");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
mutation: "update",
}),
}),
),
);
});
it("wakes the assignee when a board user moves an assigned review back to todo", async () => {
const issue = makeIssue("in_review");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
mutation: "update",
}),
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
source: "issue.status_change",
}),
}),
);
});
it("does not wake the assignee when the assignee agent moves its own review back to todo", async () => {
const issue = makeIssue("in_review");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(await installActor(createApp(), agentActor()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not enqueue a resume wake when an unassigned review moves back to todo", async () => {
const issue = { ...makeIssue("in_review"), assigneeAgentId: null };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("keeps the existing backlog to todo assignee wake", async () => {
const issue = makeIssue("backlog");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({ reason: "issue_status_changed" }),
);
});
it("wakes the assignee when an assigned done issue moves back to todo", async () => {
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
mutation: "update",
}),
contextSnapshot: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
source: "issue.status_change",
}),
}),
);
});
it("explicit same-agent resume works through the PATCH comment path", async () => {
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch),
);
const res = await request(await installActor(createApp(), agentActor()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "please validate the follow-up", resume: true });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "todo",
actorAgentId: "22222222-2222-4222-8222-222222222222",
actorUserId: null,
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.comment_added",
details: expect.objectContaining({
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
requestedByActorType: "agent",
requestedByActorId: "22222222-2222-4222-8222-222222222222",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "done",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
});
it("keeps generic same-agent comments on closed issues inert", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "follow-up note without intent" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("explicit same-agent resume comments reopen closed issues and mark the wake payload", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}),
);
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please validate the follow-up", resume: true });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.comment_added",
details: expect.objectContaining({
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "done",
resumeIntent: true,
followUpRequested: true,
}),
contextSnapshot: expect.objectContaining({
wakeReason: "issue_reopened_via_comment",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
});
it("honors explicit agent resume intent from a default-open peer as an agent-class wake", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}),
);
mockAccessService.decide.mockImplementation(
async (input: { action?: string }) => ({
allowed: input.action !== "tasks:manage_active_checkouts",
action: input.action,
reason:
input.action === "issue:comment" || input.action === "issue:mutate"
? "allow_visible_issue_write"
: "deny_missing_grant",
explanation: "Allowed by the shared visible-issue write rule.",
}),
);
const res = await request(
await installActor(
createApp(),
agentActor("44444444-4444-4444-8444-444444444444"),
),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "restart someone else's work", resume: true });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
requestedByActorType: "agent",
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({ resumeIntent: true }),
}),
);
});
it("bounds a cross-agent reply loop: peer comments wake and assignee self-replies do not", async () => {
const agentA = "44444444-4444-4444-8444-444444444444";
const agentB = "22222222-2222-4222-8222-222222222222";
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: agentB,
});
let res = await request(await installActor(createApp(), agentActor(agentA)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "A asks B for input" });
expect(res.status).toBe(201);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
agentB,
expect.objectContaining({
reason: "issue_commented",
requestedByActorType: "agent",
requestedByActorId: agentA,
}),
),
);
mockHeartbeatService.wakeup.mockClear();
res = await request(await installActor(createApp(), agentActor(agentB)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "B replies on B's own issue" });
expect(res.status).toBe(201);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: agentA,
});
res = await request(await installActor(createApp(), agentActor(agentB)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "B explicitly comments back on A's issue" });
expect(res.status).toBe(201);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
agentA,
expect.objectContaining({
reason: "issue_commented",
requestedByActorType: "agent",
requestedByActorId: agentB,
}),
),
);
});
it.each([
[
"comment",
(app: express.Express) =>
request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "cross-issue attempt 21" }),
],
[
"update",
(app: express.Express) =>
request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "cross-issue attempt 21" }),
],
] as const)(
"fails closed when a run exceeds the cross-issue %s cap",
async (kind, sendRequest) => {
const agentA = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: agentA,
responsibleUserId: null,
contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
});
mockObserveCrossIssueInfluence.mockResolvedValue({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
const res = await sendRequest(
await installActor(createApp(), agentActor(agentA)),
);
expect(res.status).toBe(429);
expect(res.body.error).toContain(
"limited to 20 cross-issue comments or updates",
);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
runId: "run-1",
agentId: agentA,
targetIssueId: "11111111-1111-4111-8111-111111111111",
kind,
}),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
},
);
it.each([
["hide", "2026-08-04T18:00:00.000Z"],
["unhide", null],
] as const)(
"counts a cross-issue %s PATCH before mutation",
async (_label, hiddenAt) => {
const agentA = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
hiddenAt:
hiddenAt === null ? new Date("2026-08-04T17:00:00.000Z") : null,
});
mockObserveCrossIssueInfluence.mockResolvedValue({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
const res = await request(
await installActor(createApp(), agentActor(agentA)),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ hiddenAt });
expect(res.status).toBe(429);
expect(res.body.error).toContain(
"limited to 20 cross-issue comments or updates",
);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledTimes(1);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ kind: "update" }),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
},
);
it("counts bundled cross-issue PATCH updates and comments separately before mutation", async () => {
const agentA = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
});
const app = await installActor(createApp(), agentActor(agentA));
mockObserveCrossIssueInfluence
.mockResolvedValueOnce({
allowed: true,
mode: "enforce",
count: 20,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
})
.mockResolvedValueOnce({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
const res = await request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "Bundled update", comment: "Bundled comment" });
expect(res.status).toBe(429);
expect(
mockObserveCrossIssueInfluence.mock.calls.map(([, input]) => input.kind),
).toEqual(["update", "comment"]);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("counts a comment-only cross-issue PATCH once", async () => {
const agentA = "44444444-4444-4444-8444-444444444444";
const existing = {
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
};
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...existing,
...patch,
}),
);
const res = await request(
await installActor(createApp(), agentActor(agentA)),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Comment without an issue update" });
expect(res.status).toBe(200);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledTimes(1);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ kind: "comment" }),
);
});
it.each([
[
"comment",
(app: express.Express) =>
request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "cross-issue write" }),
],
[
"update",
(app: express.Express) =>
request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "cross-issue write" }),
],
] as const)(
"rejects cross-issue %s writes without a run header",
async (_kind, sendRequest) => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const actor = {
...agentActor("44444444-4444-4444-8444-444444444444"),
runId: undefined,
};
const res = await sendRequest(await installActor(createApp(), actor));
expect(res.status).toBe(403);
expect(res.body.details).toEqual({
code: "cross_issue_influence_run_context_required",
});
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockObserveCrossIssueInfluence).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
},
);
it(
"rejects comment and PATCH writes when influence validation rejects the run",
async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockObserveCrossIssueInfluence.mockRejectedValue(
new HttpError(
403,
"Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained",
{ code: "cross_issue_influence_run_context_required" },
),
);
const actor = agentActor("44444444-4444-4444-8444-444444444444");
const commentRes = await request(await installActor(createApp(), actor))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "cross-issue write" });
const updateRes = await request(await installActor(createApp(), actor))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "cross-issue write" });
for (const res of [commentRes, updateRes]) {
expect(res.status).toBe(403);
expect(res.body.details).toEqual({
code: "cross_issue_influence_run_context_required",
});
}
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledTimes(2);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
},
);
it("rejects explicit resume intent under an active pause hold", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue({
holdId: "hold-1",
rootIssueId: "root-1",
issueId: "11111111-1111-4111-8111-111111111111",
isRoot: false,
mode: "pause",
reason: "reviewing",
releasePolicy: null,
});
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please resume", resume: true });
expect(res.status).toBe(409);
expect(res.body.error).toBe(
"Issue follow-up blocked by active subtree pause hold",
);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("honors explicit resume intent on cancelled issues", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("cancelled"));
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("cancelled"),
...patch,
}),
);
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please resume", resume: true });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
requestedByActorType: "agent",
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
reopenedFrom: "cancelled",
resumeIntent: true,
}),
}),
);
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
executionRunId: "run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
}),
);
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "running",
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
comment: "hello",
interrupt: true,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
"run-1",
"Interrupted by board comment",
expect.objectContaining({
errorCode: "operator_interrupted",
resultJson: expect.objectContaining({
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: "11111111-1111-4111-8111-111111111111",
}),
eventMessage: "run interrupted by board comment",
eventPayload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
source: "issue_comment_interrupt",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "heartbeat.cancelled",
details: expect.objectContaining({
source: "issue_comment_interrupt",
issueId: "11111111-1111-4111-8111-111111111111",
cancellationKind: "operator_interrupted",
operatorInterrupted: true,
}),
}),
);
});
it("cancels an active run when an issue is marked cancelled", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
}),
);
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "running",
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "cancelled" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "heartbeat.cancelled",
details: expect.objectContaining({
source: "issue_status_cancelled",
issueId: "11111111-1111-4111-8111-111111111111",
}),
}),
);
});
it("does not cancel active runs when an issue is marked done", async () => {
const issue = {
...makeIssue("in_progress"),
executionRunId: "run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
}),
);
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "running",
});
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("writes decision ids into executionState and inserts the decision inside the transaction", async () => {
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "approval",
participants: [{ type: "user", userId: "local-board" }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "approval",
currentParticipant: { type: "user", userId: "local-board" },
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}),
);
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done", comment: "Approved for ship" });
expect(res.status).toBe(200);
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
executionState: expect.objectContaining({
status: "completed",
lastDecisionId: expect.any(String),
lastDecisionOutcome: "approved",
}),
}),
mockTx,
expect.any(Array),
expect.any(Array),
);
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<
string,
any
>;
const decisionId = updatePatch.executionState.lastDecisionId;
expect(mockTxInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
id: decisionId,
issueId: "11111111-1111-4111-8111-111111111111",
outcome: "approved",
body: "Approved for ship",
}),
);
});
it("auto-approves a reviewer comment with the APPROVED review marker", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-1",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-1",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "comment-review-1",
issueId: issue.id,
body: reviewBody,
});
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "done",
actorAgentId: reviewerAgentId,
actorUserId: null,
executionState: expect.objectContaining({
status: "completed",
lastDecisionId: expect.any(String),
lastDecisionOutcome: "approved",
}),
}),
mockTx,
undefined,
expect.any(Array),
);
});
it("auto-approves a reviewer comment with structured review metadata", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "kind: review\ndecision: approved\nsummary: ship it";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-2",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-2",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "comment-review-2",
issueId: issue.id,
body: reviewBody,
});
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "done",
actorAgentId: reviewerAgentId,
actorUserId: null,
executionState: expect.objectContaining({
status: "completed",
lastDecisionId: expect.any(String),
lastDecisionOutcome: "approved",
}),
}),
mockTx,
undefined,
expect.any(Array),
);
});
it("auto-approves a reviewer comment and wakes dependents when the final blocker resolves", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const dependentAgentId = "44444444-4444-4444-8444-444444444444";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: reviewerAgentId,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
parentId: null,
};
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-3",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}),
);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
{
id: "dependent-1",
assigneeAgentId: dependentAgentId,
blockerIssueIds: [issue.id],
},
]);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-3",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(mockIssueService.listWakeableBlockedDependents).toHaveBeenCalledWith(
issue.id,
);
await waitForWakeup(() => {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
dependentAgentId,
expect.objectContaining({
reason: "issue_blockers_resolved",
payload: expect.objectContaining({
issueId: "dependent-1",
resolvedBlockerIssueId: issue.id,
blockerIssueIds: [issue.id],
}),
}),
);
});
});
it("does not wake the returnAssignee with issue_commented when auto-approval reassigns the issue", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const returnAssigneeAgentId = "22222222-2222-4222-8222-222222222222";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: reviewerAgentId,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: returnAssigneeAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
const reviewBody = "## Review: APPROVED";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-5",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
// Simulate the policy transition reassigning the now-done issue back to the
// returnAssignee so the post-mutation assignee differs from the reviewer.
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
assigneeAgentId: returnAssigneeAgentId,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-stale-isclosed",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
// Allow any deferred wakeup task to flush before asserting it never fired.
await new Promise((resolve) => setImmediate(resolve));
const issueCommentedWakeCalls =
mockHeartbeatService.wakeup.mock.calls.filter(
([, wakeup]: [string, { reason?: string }]) =>
wakeup?.reason === "issue_commented",
);
expect(issueCommentedWakeCalls).toEqual([]);
});
it("does not auto-approve APPROVED comments from a non-review participant", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-4",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: null,
authorUserId: "local-board",
});
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
// The human comment and optional receipt commit together; no approval update occurs.
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ reason: "issue_blockers_resolved" }),
);
});
it("does not auto-approve when actor kind disagrees with the participant kind", async () => {
// The reviewer participant is a USER, but the request actor is an AGENT whose id collides
// with that user's id. The auto-approval gate must dispatch on actor kind, not just id, so
// this comment must follow the normal non-approval insert path.
const sharedId = "33333333-3333-4333-8333-333333333333";
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [{ type: "user", userId: sharedId }],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "user", userId: sharedId },
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
const reviewBody = "## Review: PAP-580 - APPROVED\n\nShipping.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-mismatched-kind",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: sharedId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: sharedId,
companyId: "company-1",
source: "agent_key",
runId: "run-kind-mismatch",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(mockDb.transaction).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("does not auto-approve structured metadata separated by a blank line", async () => {
// Even though both `kind: review` and `decision: approved` appear, a blank line between
// them means they are not on truly consecutive lines, so the strict regex must reject it.
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "kind: review\n\ndecision: approved";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-blank-line-metadata",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-blank-line-metadata",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(mockDb.transaction).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("does not auto-approve reviewer comments without an approval marker", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "Looks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-5",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-5",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(mockDb.transaction).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not auto-approve approval comments outside the in_review status", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = {
...makeIssue("todo"),
status: "in_progress",
assigneeAgentId: reviewerAgentId,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-6",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-6",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
expect(res.status).toBe(201);
expect(mockDb.transaction).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
describe.each([
{ name: "uppercase negation", body: "## Review: NOT APPROVED" },
{
name: "uppercase negation with trailing period",
body: "## Review: NOT APPROVED.",
},
{ name: "mixed-case negation", body: "## Review: Not approved." },
{ name: "do-not phrasing", body: "## Review: Do not approve" },
{ name: "present-progressive negation", body: "## Review: Not approving" },
{
name: "structured rejection",
body: "kind: review\ndecision: rejected\nsummary: ship it",
},
{
name: "structured changes_requested",
body: "kind: review\ndecision: changes_requested\nsummary: ship it",
},
{
name: "disjoint structured metadata across prose",
body: "kind: review\n\nThe previous sprint decision: approved by stakeholders, but this round still needs work.",
},
{
name: "disjoint structured metadata with summary line between",
body: "kind: review\nsummary: needs more work\ndecision: approved",
},
])("does not auto-approve negated approval phrasings ($name)", ({ body }) => {
it("rejects the auto-approval transition and keeps the comment as a regular comment", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-negated",
issueId: issue.id,
companyId: issue.companyId,
body,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-negated",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body });
expect(res.status).toBe(201);
expect(mockDb.transaction).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
});
describe.each([
{ name: "uppercase approval", body: "## Review: APPROVED" },
{ name: "trailing punctuation", body: "## Review: APPROVED!" },
{ name: "ticketed approval", body: "## Review: PAP-580 - APPROVED" },
{ name: "lowercase approval", body: "## Review: LGTM, approved" },
{
name: "approval with body context",
body: "## Review: APPROVED\n\nReady to ship.",
},
])("auto-approves positive approval phrasings ($name)", ({ body }) => {
it("triggers the auto-approval transition", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-positive",
issueId: issue.id,
companyId: issue.companyId,
body,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
...issue,
...patch,
executionState: patch.executionState,
status: "done",
completedAt: new Date(),
updatedAt: new Date(),
_tx: tx,
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-positive",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body });
expect(res.status).toBe(201);
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({ status: "done" }),
mockTx,
undefined,
expect.any(Array),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
status: "done",
source: "auto_approval_comment",
_previous: { status: "in_review" },
}),
}),
);
});
});
it("rolls back the comment when the auto-approval status transition fails", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-atomic",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
mockIssueService.update.mockRejectedValue(
unprocessable("Issue can only have one assignee"),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-atomic",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
// The route must propagate the 422 (no successful 201) and must insert the
// comment inside the same transaction as the status update so the comment
// rolls back when the status update fails.
expect(res.status).toBe(422);
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
reviewBody,
expect.objectContaining({ agentId: reviewerAgentId }),
expect.any(Object),
mockTx,
);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("rolls back the auto-approval comment when the issue is concurrently deleted", async () => {
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
const issue = createReviewIssue(policy, reviewerAgentId);
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.addComment.mockResolvedValue({
id: "comment-review-missing",
issueId: issue.id,
companyId: issue.companyId,
body: reviewBody,
createdAt: new Date(),
updatedAt: new Date(),
authorAgentId: reviewerAgentId,
authorUserId: null,
});
// Simulate the concurrent-delete race: svc.update resolves to null instead of throwing.
mockIssueService.update.mockResolvedValue(null);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: reviewerAgentId,
companyId: "company-1",
source: "agent_key",
runId: "run-review-missing",
}),
)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: reviewBody });
// The route must surface a 404 AND keep the transaction rollback path intact:
// the addComment INSERT must run inside the same transaction that the throw aborts,
// so the comment cannot survive when the status update finds no issue.
expect(res.status).toBe(404);
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
reviewBody,
expect.objectContaining({ agentId: reviewerAgentId }),
expect.any(Object),
mockTx,
);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [
{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_progress",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
executionPolicy: policy,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
runId: "run-1",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
reviewRequest: {
instructions:
"Please verify the fix against the reproduction steps and note any residual risk.",
},
});
expect(res.status).toBe(200);
expect(res.body.assigneeAgentId).toBe(
"33333333-3333-4333-8333-333333333333",
);
expect(res.body.assigneeUserId).toBeNull();
expect(res.body.executionState).toMatchObject({
status: "pending",
currentStageType: "review",
currentParticipant: {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
},
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
reviewRequest: {
instructions:
"Please verify the fix against the reproduction steps and note any residual risk.",
},
});
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
expect.objectContaining({
reason: "execution_review_requested",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
executionStage: expect.objectContaining({
wakeRole: "reviewer",
stageType: "review",
reviewRequest: {
instructions:
"Please verify the fix against the reproduction steps and note any residual risk.",
},
allowedActions: ["approve", "request_changes"],
}),
}),
}),
),
);
});
it("wakes the return assignee with execution_changes_requested", async () => {
const policy = await normalizePolicy({
stages: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
type: "review",
participants: [
{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
],
},
],
})!;
const issue = {
...makeIssue("todo"),
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: policy.stages[0].id,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
},
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}),
);
const res = await request(
await installActor(createApp(), {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-2",
}),
)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({
status: "in_progress",
comment: "Needs another pass",
});
expect(res.status).toBe(200);
await waitForWakeup(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "execution_changes_requested",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
executionStage: expect.objectContaining({
wakeRole: "executor",
stageType: "review",
lastDecisionOutcome: "changes_requested",
allowedActions: ["address_changes", "resubmit"],
}),
}),
}),
),
);
});
});