Files
PaperClipAI/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
T
Nicky LeachandClaude Opus 5.5 11ac90e31f fix(build): typecheck server/src/__tests__ and fix every type error (#18739)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server has about 1,100 test and helper files in
`server/src/__tests__`
> - `server/tsconfig.json` excludes that directory, Vitest does not
check types, and no script runs the tsconfig in that directory
> - So a test can call a function with the wrong arguments and still
pass, and no tool reports it
> - This gap hid 2,168 real type errors, and some of them hid tests that
did not test what they claim
> - This pull request fixes all of these errors and adds the directory
to the server layers from paperclipai/paperclip#18737
> - The benefit is that a type error in a server test now fails the
typecheck, with no baseline and no backlog

## Linked Issues or Issue Description

Refs paperclipai/paperclip#18737, which is merged and added the server
layers. Refs paperclipai/paperclip#18738, which is merged and checks the
test projects outside `pnpm -r`, so it is not a duplicate. Refs
paperclipai/paperclip#18137, which records that this directory has no
check.

**What happened?**

No tool checked the types of 1,074 of the 1,101 files in
`server/src/__tests__`. I searched `package.json`, the workflows, and
all tsconfig files. 27 files are in the program of another tsconfig, and
these 27 have 0 errors. 11 of the 27 are in
`helpers/maintenance/tsconfig.json`. No script ran that project when I
measured this. paperclipai/paperclip#18738 now runs it.

**Expected behavior**

The server typecheck checks these files, and a type error fails the
check.

**Steps to reproduce**

1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json
--noEmit`. It reports 6,536 errors and needs 12.3 GiB.
2. Add a type error to any file in `server/src/__tests__`.
3. Run `pnpm --filter @paperclipai/server typecheck`. It passes.

**Paperclip version or commit**

I measured the error breakdown below on commit `8dfd632452`, with
TypeScript 7.0.2. The branch now includes master up to
paperclipai/paperclip#18865.

**Deployment mode**

Not applicable. This issue applies to development and CI. No production
source file in the server changes.

### Error breakdown

Each row adds one change to the row above.

| Step | Total errors | Change | Cause |
| --- | --- | --- | --- |
| Config as committed | 6,536 | | |
| Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request
types were missing. |
| Set `rootDir` to the repository root | 2,436 | -2,011 | Config. |
| Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest
accepts `.ts` imports. |
| Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has
`Promise.withResolvers`. |
| Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8
`TS7016` errors and shows new real errors. |
| Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing.
|
| Count only files in `server/src` | 2,058 | -84 | Other packages: 53
Daytona, 29 Runner test, 2 UI. |

So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058
are real type errors in 314 files. The 29 Runner test errors are also
real, which gives 2,087 errors. Tests that master added after this
measurement had 81 more errors, so this pull request fixes 2,168 errors.

This table classifies the first 2,058 errors.

| Kind of real error | Errors |
| --- | --- |
| Wrong argument or assignment type | 1,023 |
| Stale or partial fixture objects | 525 |
| Property that the type does not have | 317 |
| Missing narrowing of null or unknown | 101 |
| Implicit any | 60 |
| Missing name or module | 16 |
| Other | 16 |

### What the type errors hid

Most errors were stale fixtures and loose mocks. These errors hid real
test problems, and this pull request fixes them:

- **A test that never ran.** `instrumentation.test.ts` required
`sdk-trace-base`, which is not installed. So 3 span tests were skipped.
They now run and pass.
- **Assertions that could not fail.**
- `issue-thread-interactions-service` used a company ID as an issue ID.
- `status-card-evaluation-work` compared `[]` with `[]` in a rollback
check.
- `slack-conversation-lifecycle` read `item.issueId`, which does not
exist.
  - `email-channels` used a permission key that does not exist.
- `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is
not exported. The value was undefined.
- **Postgres processes that did not stop.** 11 test files called `stop`
on the test database helper. That method does not exist, and the helper
method is `cleanup`.
- **Arguments that the service ignored.**
- `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft,
1Password, and Railway tests.
- 108 calls passed `"owner-user"` to a parameter that expects an options
object.
- 9 chat wizard fixtures passed `managementContext` as `source`, so they
ran `existing_app` and not `manifest`.
  - 2 tests passed a `{ userId }` actor that the function ignores.
- Inserts into `environments` passed a `companyId` column that the table
does not have.
- **Problems in tests that master added during this work.**
- `sandbox-creation-operations` read `database.connectionString` when an
external test database was set. In that mode `database` is undefined, so
the fixture would stop with a TypeError. It now uses the external URL.
- `idle-completed-history` set a Dot operation to `dispatched`. That is
a Muse status, and the Dot type does not have it. The fixture now uses
`admitted`, which is a real in-flight Dot status.

### Possible product problems, not changed

These may be real problems in production code or types. I did not change
them, because this pull request does not change production behavior:

- `decisions-service` `service.create` does not validate the lenient
`cancel_issue_tree` input.
- `createConnection` ignores `input.connectionPurpose`.
- The `createConnection` route schema default stores `{}` for
`transportConfig`.
- `prepareManagedAiRuntime` accepted the invalid value `mode:
"connection"`.
- `createBinding` accepts invalid `projectionClass` strings outside
class 3.
- `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by
spread.
- `routineVariableSchema.label` is optional, but `RoutineVariable.label`
is required.
- `ToolRunContext.projectId` may be too strict.
- `withdrawInteraction` reads `issue.status`, which its type omits.
- `issueService.list` and `reconcileNativeFinalizations` omit fields
that they return at runtime.
- `createGitHubExternalObjectProvider` handles `tokenProvider: null`,
but its type forbids this value.

## What Changed

- Fixed the 2,168 type errors. The diff changes 322 test and helper
files.
- Corrected `server/src/__tests__/tsconfig.json`. Editors and the
typecheck now read the same options and report the same errors.
- Added three `test-tree` layers to
`server/scripts/typecheck-layers.mjs`. They read the server layers as
declarations, and any error fails them. A hash of the path selects the
shard, so a new file does not move other files.
- Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests
reach Daytona plugin source, and the root install does not have
`@daytonaio/sdk`.
- Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use
`.js` extensions, because server tests import this file.
- Made `declarationsConfig` and `--declarations-only` skip the
`test-tree` layers, because these layers emit nothing. Other projects
read only the source layers. Added a test for this.
- Updated `doc/DEVELOPING.md`.

## Verification

Run these commands:

```sh
pnpm --filter @paperclipai/server typecheck
pnpm exec vitest run server/scripts/typecheck-layers.test.mjs
```

Results on macOS (arm64), Node 24, 16 cores. Each check ran after the
merge of paperclipai/paperclip#18738 unless the row says otherwise:

| Check | Commit | Result |
| --- | --- | --- |
| Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers |
`6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB |
| `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e`
| Exit 0, 0 errors, 42 seconds, peak 3.25 GiB |
| The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0
errors |
| `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` |
`01db5634e4` | 90 passed. These include real compiler runs for a pass, a
type error, and an invalid tsconfig. |
| `tsconfig.declarations.json` after a full server typecheck |
`0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No
generated config names a `tsconfig.layers.json` file. |
| Owned runtime test in `idle-completed-history` | `e8f2f448ac` |
Passed, with the fix that master later replaced |
| `idle-incoming-wake`, `plugin-idle-manager`, and the changed
`chat-channels` test | `6e45a08573` | 30 passed |
| Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed |
| The 19 server test files that changed since `eab4dcc1f4`, and
`typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The
2 failures are in `chat-channels` and are in the list of 7 below. |

The peak is the largest single process, from `/usr/bin/time -l`. Peaks
for the server typecheck were between 3.42 GiB and 3.56 GiB across 8
runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2
runs. I did not measure a cgroup.

**Runtime check of the fixes.** Types do not prove that a test still
works. So I ran all 309 changed server test files, 11,929 tests, before
the rebase onto paperclipai/paperclip#18737:

| Result | Tests | Detail |
| --- | --- | --- |
| Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests
that passed in a later run, as described below. |
| Failed | 7 | The same 7 tests also fail on the commit before the
fixes. |
| Skipped | 9 | These tests have their own skip conditions: `sentry` 5,
and 1 each in `bundled-plugin-registration`, `customer-success-cloud`,
`onepassword-connection`, and `workspace-runtime-https-live-exercise`. |

The 7 tests that fail with and without this change, on my machine:

- 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on
a `SKILL.md` file. The path is in the parent checkout, above the git
worktree that I used, so I expect that the worktree is the cause. I did
not prove this.
- `workspace-instance-cleanup` compares `/private/var/...` with
`/var/...`. This is a macOS symlink difference.
- 2 tests in `git-reproducible-managed-checkouts` get
`unknown_base_git_files` and expect `unpushed_managed_checkout`. I did
not find the cause.
- 1 more test in `chat-channels`, `materializes direct external-chat
finals once`, gets `undefined`. I did not find the cause.

CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`.

Failures during this work:

- My fix to `agent-runtime-connector-access` changed an invalid
permission key in the setup, but not in the assertion. The runtime check
found this. I corrected the assertion, and the file passes.
- `heartbeat-plugin-environment` failed one wait in the full run. It
passed three times alone, so I think that load caused the failure. It
can fail again under load.
- `dot-runner` skipped all 54 tests in the full run because `cargo` was
not on my `PATH`. It then passed 54 of 54 with the toolchain on the
`PATH`.
- My first `lib` value removed the default DOM types, and the error
count went up by 375. I added the DOM libraries.
- My Daytona stub with bare `any` left 48 errors, because generic
helpers then inferred `unknown`. I added shapes that return promises.
- One fix to `agent-secrets-routes` used `class_3_static_lease`, and the
test failed. That class allows only specific paths. I reverted to
`unclassified`.
- `ci / General tests (server (8/12))` failed on `259737b700` because of
the flaky port in `idle-completed-history`. paperclipai/paperclip#18853
fixed this test on master, so this pull request keeps that fix.

**Audit of the diff.** I counted these patterns in added and removed
lines, to find fixes that only hide an error:

| Pattern | Added | Removed |
| --- | --- | --- |
| `@ts-ignore` | 0 | 0 |
| `@ts-expect-error` | 0 | 1 |
| `.skip` | 0 | 0 |
| `as any` | 9 | 9 |
| `: any` | 11 | 2 |
| `as never` | 8 | 2 |
| `as unknown as` | 35 | 4 |
| `Promise<unknown>` | 16 | 5 |
| `expect(` | 128 | 113 |

No hunk removes more `expect(` calls than it adds. Three removals of
test content were intentional, and I checked each one:

- `muse-control` removed `signalToken`. Line 1012 already asserts that
it is absent.
- `merged-pr-confirmation-sweep` removed legacy aliases. Other tests
cover them.
- `heartbeat-workspace-branch-containment` removed a `finalize` branch
that no test row can reach.

Some partial fixtures stay unchecked through `as unknown as` and
`Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes`
and `issue-comment-reopen-routes`.

**Accuracy of the check.** One program over the tree and the three
shards reported the same errors at the same positions. Memory, as the
peak of the largest process on one thread, was 3.93 GiB for one program
and 2.43 GiB for the largest shard. I measured these two values before
the fixes.

Not run or not proven:

- I did not run the full `pnpm test:run`, or a root `pnpm build`. I
expect CI to run the full suites.
- I did not run the 4 GiB container check, or the full
`scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so
they are not a Linux cgroup measurement.
- I did not run the UI typecheck after the merges.
- `workspace-runtime-https-live-exercise` is skipped, so its `cleanup`
fix did not run.

## Risks

- **A large diff in test files.** 328 files change. A pull request that
edits the same tests can get merge conflicts. New tests on master can
also add type errors before this merges. Any such error fails this check
and needs a fix here.
- **A higher cost for changes to source types.** A change to a type can
add errors in test files that the author did not touch. The author must
then fix them. This is the purpose of the check, but it is new work.
- **Changed test behavior.** Some tests now run paths that they did not
run before, such as the 9 chat wizard fixtures and the 3 span tests.
They pass here, but they can be more sensitive to timing or to the
environment.
- **The Daytona stub can go stale.** The stub is written by hand. If the
plugin uses a new SDK member, the typecheck reports an error until
someone updates the stub.
- **The release path.** `release-verify.yml` and `scripts/release.sh`
run `pnpm -r typecheck`, so they now run this check.
- **The tsconfig change.** The test transform reads
`server/src/__tests__/tsconfig.json`. The options that I changed are for
type checks, and the runs above passed.
- **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and
in most sandbox provider plugins exclude `*.test.ts`. This check does
not read most of those test files, so they can still drift. That work is
separate from this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with
reasoning, tool use, code execution, and subagents. The runtime does not
show the context window size.

## Checklist

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:27:38 -08:00

3556 lines
154 KiB
TypeScript

import { Readable } from "node:stream";
import express from "express";
import request from "supertest";
import { getTableName, type SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
import type { LogActivityInput } from "../services/activity-log.js";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
import { inboxBlockedActivity } from "../../../tests/runner-e2e/product-journeys/inbox-triage-fixture.js";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const ownerAgentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "44444444-4444-4444-8444-444444444444";
const ownerRunId = "55555555-5555-4555-8555-555555555555";
const recoveryActionId = "77777777-7777-4777-8777-777777777777";
const mockIssueService = vi.hoisted(() => ({
addComment: vi.fn(),
assertCheckoutOwner: vi.fn(),
create: vi.fn(),
createChild: vi.fn(),
decomposeAcceptedPlan: vi.fn(),
getAttachmentById: vi.fn(),
getByIdentifier: vi.fn(),
getById: vi.fn(),
getByIdForUpdate: vi.fn(),
getComment: vi.fn(),
getDependencyReadiness: vi.fn(),
getRelationSummaries: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
list: vi.fn(),
listAttachments: vi.fn(),
listComments: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
remove: vi.fn(),
removeAttachment: vi.fn(),
update: vi.fn(),
findMentionedAgents: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
list: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
getInvocationBlock: vi.fn(async (): Promise<unknown> => null),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(async () => null),
}));
const mockDocumentService = vi.hoisted(() => ({
upsertIssueDocument: vi.fn(),
}));
const mockWorkProductService = vi.hoisted(() => ({
createForIssue: vi.fn(),
getById: vi.fn(),
latestRunDiffSummary: vi.fn(),
resolveCommitDiffSummary: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}));
const mockStorageService = vi.hoisted(() => ({
provider: "local_disk",
putFile: vi.fn(),
getObject: vi.fn(),
headObject: vi.fn(),
deleteObject: vi.fn(),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expirePendingInteractionsForTerminalIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(async () => []),
listForIssue: vi.fn(async () => []),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
link: vi.fn(),
unlink: vi.fn(),
listApprovalsForIssue: vi.fn(async (): Promise<unknown[]> => []),
}));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async (): Promise<unknown> => null),
listActiveForIssues: vi.fn(async () => new Map()),
resolveActiveForIssue: vi.fn(async (): Promise<unknown> => null),
}));
const mockTaskWatchdogService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
revalidateMutationScope: vi.fn(async (): Promise<{
allowed: boolean;
reason?: string;
classification: { state: string; stopFingerprint?: string; liveIssueIds?: string[] };
}> => ({
allowed: true,
classification: { state: "stopped", stopFingerprint: "task_watchdog_stop:test" },
})),
reconcileForIssueAndAncestors: vi.fn(async () => ({
checked: 0,
triggered: 0,
skipped: 0,
watchdogIssueIds: [],
})),
upsertForIssue: vi.fn(),
disableForIssue: vi.fn(async () => null),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async (): Promise<unknown> => null),
getActiveRunForAgent: vi.fn(async (): Promise<unknown> => null),
cancelRun: vi.fn(async (): Promise<unknown> => null),
resumeTaskRetry: vi.fn(async () => undefined),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
const mockChatRunRetries = vi.hoisted(() => ({
prepareFailedChatRunRetry: vi.fn(),
processFailedChatRunRetry: vi.fn(),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => new Map()),
getIssueSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
getProjectSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
listForIssue: vi.fn(async () => []),
refreshIssueObjects: vi.fn(async () => []),
syncCommentSafely: vi.fn(async () => undefined),
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
const mockIssueTreeControlService = vi.hoisted(() => ({ getActivePauseHoldGate: vi.fn(async (): Promise<unknown> => null) }));
const mockLogActivity = vi.hoisted(() => vi.fn(async (_db: unknown, _input: LogActivityInput) => undefined));
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn(async () => null));
const mockRetryWorkspaceExport = vi.hoisted(() => vi.fn());
const mockRequestWorkspaceRecovery = vi.hoisted(() => vi.fn());
const mockRequestTaskRetry = vi.hoisted(() => vi.fn());
const mockOnboardingFirstTaskDependenciesAllowed = vi.hoisted(() => vi.fn(async () => undefined));
const mockGetExperimental = vi.hoisted(() => vi.fn());
const mockResumeWorkspaceFinalization = vi.hoisted(() => vi.fn());
function registerRouteMocks() {
vi.doMock("../services/instance-settings.js", async () => {
const actual = await vi.importActual<typeof import("../services/instance-settings.js")>("../services/instance-settings.js");
return { ...actual, instanceSettingsService: (...args: Parameters<typeof actual.instanceSettingsService>) => ({
...actual.instanceSettingsService(...args), getExperimental: mockGetExperimental,
}) };
});
vi.doMock("../services/task-retry.js", () => ({ requestTaskRetry: mockRequestTaskRetry }));
vi.doMock("../services/onboarding-first-task-admission.js", async () => ({
...await vi.importActual<typeof import("../services/onboarding-first-task-admission.js")>("../services/onboarding-first-task-admission.js"),
assertOnboardingFirstTaskDependenciesAllowed: mockOnboardingFirstTaskDependenciesAllowed,
}));
vi.doMock("../services/task-interruption-presentation.js", () => ({
taskInterruptionPresentation: vi.fn(async () => null),
taskInterruptionPresentations: vi.fn(async () => new Map()),
}));
vi.doMock("../services/native-runtime/native-workspace-owner-recovery.js", () => ({ resumeNativeWorkspaceAfterOwnerStop: mockResumeWorkspaceFinalization }));
vi.doMock("../services/native-runtime/native-workspace-export-retry.js", () => ({ retryNativeWorkspaceExport: mockRetryWorkspaceExport }));
vi.doMock("../services/workspace-recovery.js", () => ({ requestWorkspaceRecovery: mockRequestWorkspaceRecovery }));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/documents.js", () => ({
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => mockDocumentService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/work-products.js", () => ({
workProductService: () => mockWorkProductService,
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/cross-issue-influence-limit.js", () => ({
observeCrossIssueInfluence: mockObserveCrossIssueInfluence,
crossIssueInfluenceLimitError: vi.fn(),
crossIssueInfluenceRunContextError: () => 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" },
),
}));
vi.doMock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.doMock("../services/index.js", () => ({
issueTreeControlService: () => mockIssueTreeControlService,
ISSUE_LIST_DEFAULT_LIMIT: 100,
ISSUE_LIST_MAX_LIMIT: 500,
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
clampIssueListLimit: (value: number) => Math.min(Math.max(value, 1), 500),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
companyService: () => mockCompanyService,
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => mockDocumentService,
enrichWorkProductMetadataWithDiff: (
metadata: Record<string, unknown> | null | undefined,
summary: { additions: number | null; deletions: number | null; changedFiles: number } | null,
) => summary ? {
...(metadata ?? {}),
...(summary.additions === null ? {} : { additions: summary.additions }),
...(summary.deletions === null ? {} : { deletions: summary.deletions }),
changedFiles: summary.changedFiles,
} : metadata ?? null,
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
getExperimental: mockGetExperimental,
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => [companyId]),
}),
issueApprovalService: () => mockIssueApprovalService,
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,
taskWatchdogService: () => mockTaskWatchdogService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => mockWorkProductService,
}));
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: issueId,
companyId,
status: "in_progress",
priority: "high",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: ownerAgentId,
assigneeUserId: null,
createdByUserId: "board-user",
identifier: "PAP-1649",
title: "Owned active issue",
executionPolicy: null,
executionState: null,
checkoutRunId: null,
executionRunId: null,
hiddenAt: null,
...overrides,
};
}
function makeAgent(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
companyId,
role: "engineer",
reportsTo: null,
permissions: { canCreateAgents: false },
...overrides,
};
}
function emptyCaseHandoffQuery() {
return { where: vi.fn(() => ({
orderBy: vi.fn(() => ({ limit: vi.fn(async () => []) })),
})) };
}
function createRunContextDb(
contextSnapshot: Record<string, unknown> = {},
runAgentOrRows: string | Record<string, unknown>[] = ownerAgentId,
runId: string = ownerRunId,
chatBindings: Array<{ id: string; companyId: string; issueId: string; state: string }> = [],
emptyWakeQueue = false,
humanInboxFixture?: { userId: string },
) {
const chatBindingQueries: ReturnType<PgDialect["sqlToQuery"]>[] = [];
const runRows = Array.isArray(runAgentOrRows)
? runAgentOrRows
: [{
id: runId,
companyId,
agentId: runAgentOrRows,
agentCompanyId: companyId,
contextSnapshot,
}];
const firstRun = runRows[0] ?? {};
const runAgentId = typeof firstRun.agentId === "string" ? firstRun.agentId : ownerAgentId;
const runAgentCompanyId = typeof firstRun.agentCompanyId === "string" ? firstRun.agentCompanyId : companyId;
const rowsForSelection = async (selection: Record<string, unknown>, chatBindingQuery = false, settledRecoveryQuery = false) => {
if (chatBindingQuery) return chatBindings;
// An unknown selector has no settled recovery receipt. Returning the
// generic issue fixture here would invent an unrelated replay row.
if (settledRecoveryQuery) return [];
const keys = Object.keys(selection);
if (keys.includes("entityId")) return [];
if (keys.includes("contextSnapshot")) return runRows;
if (keys.includes("agentCompanyId")) return runRows;
if (keys.length === 0) {
const issue = await mockIssueService.getById(issueId);
return issue ? [issue] : [];
}
return [{ id: runAgentId, companyId: runAgentCompanyId, permissions: {}, role: "engineer", reportsTo: null }];
};
const buildQuery = (selection: Record<string, unknown>, chatBindingQuery = false, settledRecoveryQuery = false) => {
const whereResult = {
orderBy: vi.fn(async () => []),
limit: vi.fn(() => ({
then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
for: vi.fn(() => ({
then: async (resolve: (lockedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
})),
})),
for: vi.fn(() => ({
then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
})),
then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
};
const query = {
innerJoin: vi.fn(() => query),
where: vi.fn((condition: SQL) => {
if (chatBindingQuery) chatBindingQueries.push(new PgDialect().sqlToQuery(condition));
return whereResult;
}),
};
return query;
};
const dbStub = {
chatBindingQueries,
transaction: async (callback: (tx: unknown) => Promise<unknown>) => callback(dbStub),
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn((table: Parameters<typeof getTableName>[0]) => {
if (humanInboxFixture && getTableName(table) === "issue_approvals") {
// This disposable human task has no pending approval. A generic
// selected-ID row would invent one and bypass the real route guard.
return { innerJoin: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(async () => []) })) })) };
}
if (humanInboxFixture && getTableName(table) === "company_memberships") {
return { where: vi.fn((condition: SQL) => ({ limit: vi.fn(async () =>
new PgDialect().sqlToQuery(condition).params.includes(humanInboxFixture.userId)
? [{ id: "inbox-member" }] : []),
})) };
}
if (getTableName(table) === "case_handoffs") return emptyCaseHandoffQuery();
if (emptyWakeQueue && getTableName(table) === "agent_wakeup_requests") {
return buildQuery({}, false, true);
}
if (getTableName(table) === "issue_thread_interactions") {
return { where: vi.fn(() => ({ limit: vi.fn(async () => []) })) };
}
return buildQuery(selection, getTableName(table) === "chat_conversations", getTableName(table) === "issue_recovery_actions");
}),
})),
insert: vi.fn(() => ({ values: vi.fn(async () => undefined) })),
};
return dbStub;
}
function peerActor(overrides: Record<string, unknown> = {}) {
return {
type: "agent",
agentId: peerAgentId,
companyId,
source: "agent_key",
runId: "66666666-6666-4666-8666-666666666666",
...overrides,
};
}
function ownerActor() {
return {
type: "agent",
agentId: ownerAgentId,
companyId,
source: "agent_key",
runId: ownerRunId,
};
}
function boardActor() {
return {
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "local_implicit",
isInstanceAdmin: false,
};
}
describe("agent issue mutation checkout ownership", () => {
const routeModules = hoistModuleGraph(registerRouteMocks, async () => {
const [{ errorHandler }, { issueRoutes, __clearIssueListResponseCacheForTests }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
return { errorHandler, issueRoutes, __clearIssueListResponseCacheForTests };
});
function createApp(
actor: Record<string, unknown>,
db?: unknown,
options: { chatRunRetries?: typeof mockChatRunRetries } = {},
) {
const routeDb = db ?? createRunContextDb(
{},
typeof actor.agentId === "string" ? actor.agentId : ownerAgentId,
typeof actor.runId === "string" ? actor.runId : ownerRunId,
);
const { errorHandler, issueRoutes } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes(routeDb as any, mockStorageService as any, options));
app.use(errorHandler);
return app;
}
beforeEach(() => {
// This block loads the route module graph one time (see
// hoistModuleGraph above). As a result, the issue-list route keeps its
// response cache in memory between tests. Clear the cache before each
// test. This stops one test from reusing a cached response left behind
// by an earlier test.
routeModules.value.__clearIssueListResponseCacheForTests();
vi.clearAllMocks();
mockIssueTreeControlService.getActivePauseHoldGate.mockReset().mockResolvedValue(null);
mockChatRunRetries.prepareFailedChatRunRetry.mockReset();
mockChatRunRetries.processFailedChatRunRetry.mockReset();
mockRequestTaskRetry.mockReset().mockResolvedValue({ requestId: recoveryActionId, accepted: true, stale: false });
mockGetExperimental.mockReset().mockResolvedValue({ enableAgentChat: true });
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed:
input.action === "tasks:assign" ||
input.action === "issue:comment" ||
input.action === "issue:read" ||
input.action === "issue:mutate" ||
input.action === "company_scope:read" ||
input.action === "project:read",
action: input.action,
reason:
input.action === "tasks:assign" ||
input.action === "issue:comment" ||
input.action === "issue:read" ||
input.action === "issue:mutate" ||
input.action === "company_scope:read" ||
input.action === "project:read"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation:
input.action === "tasks:assign" ||
input.action === "issue:comment" ||
input.action === "issue:read" ||
input.action === "issue:mutate" ||
input.action === "company_scope:read" ||
input.action === "project:read"
? "Allowed by test default."
: "Missing permission.",
}));
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
mockAgentService.resolveByReference.mockReset();
mockCompanyService.getById.mockReset();
mockBudgetService.getInvocationBlock.mockReset();
mockBudgetService.getInvocationBlock.mockResolvedValue(null);
mockProjectService.getById.mockReset();
mockProjectService.getById.mockResolvedValue(null);
mockIssueService.addComment.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.create.mockReset();
mockIssueService.createChild.mockReset();
mockIssueService.decomposeAcceptedPlan.mockReset();
mockIssueService.getAttachmentById.mockReset();
mockIssueService.getByIdentifier.mockReset();
mockIssueService.getById.mockReset();
mockIssueService.getByIdForUpdate.mockReset();
mockIssueService.getComment.mockReset();
mockIssueService.getDependencyReadiness.mockReset();
mockIssueService.getDependencyReadiness.mockResolvedValue({
blockerIssueIds: [],
isDependencyReady: false,
unresolvedBlockerCount: 0,
});
mockIssueService.getRelationSummaries.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockIssueService.list.mockReset();
mockIssueService.listAttachments.mockReset();
mockIssueService.listComments.mockReset();
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockReset();
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]);
mockIssueThreadInteractionService.expireStaleRequestConfirmationsForIssueDocument.mockReset();
mockIssueThreadInteractionService.expireStaleRequestConfirmationsForIssueDocument.mockResolvedValue([]);
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockReset();
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]);
mockIssueThreadInteractionService.listForIssue.mockReset();
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueRecoveryActionService.listActiveForIssues.mockReset();
mockIssueRecoveryActionService.listActiveForIssues.mockResolvedValue(new Map());
mockIssueRecoveryActionService.resolveActiveForIssue.mockReset();
mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({
id: recoveryActionId,
companyId,
sourceIssueId: issueId,
recoveryIssueId: null,
kind: "issue_graph_liveness",
status: "resolved",
ownerType: "agent",
ownerAgentId,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:test",
evidence: {},
nextAction: "Restore a live execution path.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: null,
timeoutAt: null,
lastAttemptAt: new Date("2026-05-13T18:00:00.000Z"),
outcome: "restored",
resolutionNote: "Resolved by recovery owner",
resolvedAt: new Date("2026-05-13T18:05:00.000Z"),
createdAt: new Date("2026-05-13T17:55:00.000Z"),
updatedAt: new Date("2026-05-13T18:05:00.000Z"),
});
mockTaskWatchdogService.getActiveForIssue.mockReset();
mockTaskWatchdogService.getActiveForIssue.mockResolvedValue(null);
mockTaskWatchdogService.revalidateMutationScope.mockReset();
mockTaskWatchdogService.revalidateMutationScope.mockResolvedValue({
allowed: true,
classification: { state: "stopped", stopFingerprint: "task_watchdog_stop:test" },
});
mockTaskWatchdogService.reconcileForIssueAndAncestors.mockReset();
mockTaskWatchdogService.reconcileForIssueAndAncestors.mockResolvedValue({
checked: 0,
triggered: 0,
skipped: 0,
watchdogIssueIds: [],
});
mockTaskWatchdogService.upsertForIssue.mockReset();
mockTaskWatchdogService.disableForIssue.mockReset();
mockTaskWatchdogService.disableForIssue.mockResolvedValue(null);
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockReset();
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockReset();
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockReset();
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockIssueApprovalService.link.mockReset();
mockIssueApprovalService.unlink.mockReset();
mockIssueApprovalService.listApprovalsForIssue.mockReset();
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
mockIssueThreadInteractionService.listForIssue.mockReset();
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueService.remove.mockReset();
mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset();
mockIssueService.findMentionedAgents.mockReset();
mockLogActivity.mockClear();
mockObserveCrossIssueInfluence.mockReset();
mockObserveCrossIssueInfluence.mockResolvedValue(null);
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.createForIssue.mockReset();
mockWorkProductService.latestRunDiffSummary.mockReset();
mockWorkProductService.latestRunDiffSummary.mockResolvedValue(null);
mockWorkProductService.resolveCommitDiffSummary.mockReset();
mockWorkProductService.resolveCommitDiffSummary.mockResolvedValue(null);
mockExternalObjectService.getIssueSummaries.mockClear();
mockExternalObjectService.getIssueSummary.mockClear();
mockExternalObjectService.getProjectSummary.mockClear();
mockExternalObjectService.listForIssue.mockClear();
mockExternalObjectService.refreshIssueObjects.mockClear();
mockExternalObjectService.syncCommentSafely.mockClear();
mockExternalObjectService.syncDocumentSafely.mockClear();
mockExternalObjectService.syncIssueSafely.mockClear();
mockWorkProductService.getById.mockReset();
mockWorkProductService.remove.mockReset();
mockWorkProductService.update.mockReset();
mockStorageService.putFile.mockReset();
mockStorageService.getObject.mockReset();
mockStorageService.headObject.mockReset();
mockStorageService.deleteObject.mockReset();
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockImplementation(async (id: string) => {
if (id === ownerAgentId) return makeAgent(ownerAgentId);
if (id === peerAgentId) return makeAgent(peerAgentId);
return null;
});
mockAgentService.list.mockResolvedValue([
makeAgent(ownerAgentId),
makeAgent(peerAgentId),
]);
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: null });
mockCompanyService.getById.mockResolvedValue({ id: companyId, issuePrefix: "PAP" });
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.getByIdForUpdate.mockImplementation(async () => mockIssueService.getById());
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.getComment.mockResolvedValue({
id: "comment-1",
issueId,
companyId,
body: "Mentioned reply context.",
});
mockIssueService.list.mockResolvedValue([makeIssue()]);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
...makeIssue({
id: "88888888-8888-4888-8888-888888888888",
status: "todo",
assigneeAgentId: null,
}),
...input,
companyId,
}));
mockIssueService.createChild.mockImplementation(async (_parentId: string, input: Record<string, unknown>) => ({
issue: {
...makeIssue({
id: "99999999-9999-4999-8999-999999999999",
status: "todo",
parentId: issueId,
assigneeAgentId: null,
}),
...input,
companyId,
},
parentBlockerAdded: false,
}));
mockIssueService.decomposeAcceptedPlan.mockImplementation(async (_sourceIssueId: string, input: Record<string, unknown>) => {
const children = input.children as Record<string, unknown>[];
return {
decomposition: {
id: "decomposition-1",
status: "completed",
childIssueIds: children.map((child) => child.id),
},
childIssueIds: children.map((child) => child.id),
newlyCreatedIssues: children.map((child) => ({
...makeIssue({
id: child.id,
parentId: issueId,
status: child.status,
assigneeAgentId: child.assigneeAgentId ?? null,
}),
...child,
companyId,
})),
};
});
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue(),
...patch,
}));
mockIssueService.addComment.mockResolvedValue({
id: "77777777-7777-4777-8777-777777777777",
issueId,
companyId,
body: "comment",
});
mockIssueService.listAttachments.mockResolvedValue([]);
mockIssueService.listComments.mockResolvedValue([
{
id: "comment-1",
issueId,
companyId,
body: "Mentioned reply context.",
},
]);
mockIssueService.remove.mockResolvedValue(makeIssue({ status: "cancelled" }));
mockIssueService.getAttachmentById.mockResolvedValue({
id: "attachment-1",
issueId,
companyId,
objectKey: "issues/attachment-1/report.txt",
contentType: "text/plain",
byteSize: 6,
originalFilename: "report.txt",
});
mockIssueService.removeAttachment.mockResolvedValue({
id: "attachment-1",
issueId,
companyId,
objectKey: "issues/attachment-1/report.txt",
});
mockDocumentService.upsertIssueDocument.mockResolvedValue({
created: false,
document: {
id: "document-1",
key: "plan",
title: "Plan",
format: "markdown",
latestRevisionNumber: 2,
},
});
mockWorkProductService.createForIssue.mockResolvedValue({
id: "product-2",
issueId,
companyId,
type: "artifact",
provider: "test",
title: "Artifact",
});
mockWorkProductService.getById.mockResolvedValue({
id: "product-1",
issueId,
companyId,
type: "artifact",
});
mockWorkProductService.update.mockResolvedValue({
id: "product-1",
issueId,
companyId,
type: "artifact",
title: "Updated",
});
mockWorkProductService.remove.mockResolvedValue({
id: "product-1",
issueId,
companyId,
type: "artifact",
});
mockStorageService.putFile.mockResolvedValue({
provider: "local_disk",
objectKey: "issues/upload.txt",
contentType: "text/plain",
byteSize: 6,
sha256: "sha256",
originalFilename: "upload.txt",
});
mockStorageService.getObject.mockResolvedValue({
stream: Readable.from(Buffer.from("report")),
contentLength: 6,
});
mockStorageService.deleteObject.mockResolvedValue(undefined);
});
it("denies company-wide issue list routes for task bridge keys", async () => {
const app = await createApp(peerActor({
keyId: "99999999-9999-4999-8999-999999999999",
keyScope: {
kind: "task_bridge",
parentIssueId: issueId,
},
}));
const res = await request(app).get(`/api/companies/${companyId}/issues`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs");
expect(mockIssueService.list).not.toHaveBeenCalled();
});
it("uses the company-scope fast path on the issue list route", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => {
if (input.action === "company_scope:read") {
return {
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test company scope.",
};
}
if (input.action === "issue:read") {
throw new Error("issue:read should not be evaluated for company-scope readers");
}
return {
allowed: true,
action: input.action,
reason: "allow_test_default",
explanation: "Allowed by test default.",
};
});
const app = await createApp(boardActor());
const res = await request(app).get(`/api/companies/${companyId}/issues`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual([expect.objectContaining({ id: issueId })]);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "company_scope:read",
resource: { type: "company", companyId },
}));
expect(mockAccessService.decide).not.toHaveBeenCalledWith(expect.objectContaining({
action: "issue:read",
}));
});
it("admits ordinary Retry only for a board actor with task mutation permission", async () => {
const body = { idempotencyKey: recoveryActionId, observedRunId: ownerRunId };
const deniedAgent = await request(createApp(peerActor())).post(`/api/issues/${issueId}/retry`).send(body);
expect(deniedAgent.status).toBe(403);
expect(mockRequestTaskRetry).not.toHaveBeenCalled();
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action !== "issue:mutate", action: input.action, reason: "test_gate", explanation: "Task is read-only.",
}));
const deniedReader = await request(createApp(boardActor())).post(`/api/issues/${issueId}/retry`).send(body);
expect(deniedReader.status).toBe(403);
expect(mockRequestTaskRetry).not.toHaveBeenCalled();
mockAccessService.decide.mockResolvedValue({ allowed: true, reason: "allow_test", explanation: "Allowed." });
const accepted = await request(createApp(boardActor())).post(`/api/issues/${issueId}/retry`).send(body);
expect(accepted.status, JSON.stringify(accepted.body)).toBe(202);
expect(mockRequestTaskRetry).toHaveBeenCalledWith(expect.objectContaining({
companyId, issueId, actorId: "board-user", actorSource: "local_implicit", ...body,
}));
});
it("rejects client-selected workspace strategy and certifications on ordinary Retry", async () => {
const response = await request(createApp(boardActor())).post(`/api/issues/${issueId}/retry`).send({
idempotencyKey: recoveryActionId, observedRunId: ownerRunId,
forceFreshSession: true, workspaceRepairEvidence: { verified: true },
});
expect(response.status).toBe(400);
expect(mockRequestTaskRetry).not.toHaveBeenCalled();
});
it.each([
{ owner: "another-user", enabled: true, status: 403 },
{ owner: "board-user", enabled: false, status: 404 },
{ owner: "board-user", enabled: true, status: 202 },
])("gates Agent Chat task Retry by owner and feature ($owner, $enabled)", async ({ owner, enabled, status }) => {
mockIssueService.getById.mockResolvedValue(makeIssue({
conversationAgentId: ownerAgentId, conversationUserId: owner,
}));
mockGetExperimental.mockResolvedValue({ enableAgentChat: enabled });
const response = await request(createApp(boardActor())).post(`/api/issues/${issueId}/retry`).send({
idempotencyKey: recoveryActionId, observedRunId: ownerRunId,
});
expect(response.status, JSON.stringify(response.body)).toBe(status);
expect(mockRequestTaskRetry).toHaveBeenCalledTimes(status === 202 ? 1 : 0);
});
it.each([
["patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked" })],
["delete", (app: express.Express) => request(app).delete(`/api/issues/${issueId}`)],
[
"document upsert",
(app: express.Express) =>
request(app).put(`/api/issues/${issueId}/documents/plan`).send({ format: "markdown", body: "# blocked" }),
],
["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })],
[
"low-trust promotion",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/low-trust/promotions`).send({
sourceArtifactKind: "comment",
sourceArtifactId: recoveryActionId,
title: "Promoted artifact",
summary: "Sanitized output",
}),
],
[
"attachment upload",
(app: express.Express) =>
request(app)
.post(`/api/companies/${companyId}/issues/${issueId}/attachments`)
.attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }),
],
["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")],
])("rejects peer agent %s on another agent's active checkout", async (_name, sendRequest) => {
const res = await sendRequest(await createApp(peerActor()));
expect(res.status, JSON.stringify(res.body)).toBe(409);
// Plan §6: the run lock names the boundary and routes to the open channel.
expect(res.body.details.code).toBe("issue_write_assignee_run_lock");
expect(res.body.details.boundary).toBe("Run checkout lock");
expect(res.body.error).toContain("Who can act:");
expect(res.body.error).toContain("Comment instead");
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockDocumentService.upsertIssueDocument).not.toHaveBeenCalled();
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
expect(mockWorkProductService.update).not.toHaveBeenCalled();
expect(mockStorageService.putFile).not.toHaveBeenCalled();
expect(mockStorageService.deleteObject).not.toHaveBeenCalled();
});
it("allows mentioned peer agents to post comments without ownership of an active checkout", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment" || input.action === "issue:read",
action: input.action,
reason: input.action === "issue:comment" ? "allow_issue_mention_grant" : "deny_missing_grant",
explanation:
input.action === "issue:comment"
? "Allowed by a mention-scoped issue comment grant."
: "Missing permission.",
}));
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "I can respond here." });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"I can respond here.",
expect.any(Object),
expect.any(Object),
expect.any(Object),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("keeps visible peer comments agent-class even when authorType tries to smuggle user wake privilege", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read" || input.action === "issue:comment",
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 createApp(peerActor()))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "I was not mentioned.", authorType: "user" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"I was not mentioned.",
expect.any(Object),
expect.any(Object),
expect.any(Object),
);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
reason: "issue_commented",
requestedByActorType: "agent",
requestedByActorId: peerAgentId,
}),
));
});
it("keeps default-open peer comments on closed issues inert", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "done", assigneeAgentId: ownerAgentId }));
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment" || input.action === "issue:read",
action: input.action,
reason: input.action === "issue:comment" ? "allow_visible_issue_write" : "allow_company_agent",
explanation: "Allowed by the shared visible-issue write rule.",
}));
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "Closed issue context only." });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects peer agents from listing comments when issue read is outside their boundary", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: false,
action: input.action,
reason: "deny_low_trust_boundary",
explanation: "Issue is outside this low-trust boundary.",
}));
const res = await request(await createApp(peerActor()))
.get(`/api/issues/${issueId}/comments`);
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(res.body.error).toBe("Issue not found");
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
});
it("rejects peer agents from listing interactions when issue read is outside their boundary", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: false,
action: input.action,
reason: "deny_low_trust_boundary",
explanation: "Issue is outside this low-trust boundary.",
}));
const res = await request(await createApp(peerActor()))
.get(`/api/issues/${issueId}/interactions`);
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(res.body.error).toBe("Issue not found");
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled();
});
it("allows mentioned peer agents to list comments through an issue read grant", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read",
action: input.action,
reason: input.action === "issue:read" ? "allow_issue_mention_grant" : "deny_missing_grant",
explanation:
input.action === "issue:read"
? "Allowed by a mention-scoped issue comment grant."
: "Missing permission.",
}));
const res = await request(await createApp(peerActor()))
.get(`/api/issues/${issueId}/comments`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
id: "comment-1",
body: "Mentioned reply context.",
}),
]);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
expect(mockIssueService.listComments).toHaveBeenCalledWith(issueId, {
companyId,
afterCommentId: null,
order: "desc",
limit: null,
});
});
it("rejects peer agents from reading a specific comment when issue read is outside their boundary", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: false,
action: input.action,
reason: "deny_low_trust_boundary",
explanation: "Issue is outside this low-trust boundary.",
}));
const res = await request(await createApp(peerActor()))
.get(`/api/issues/${issueId}/comments/comment-1`);
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(res.body.error).toBe("Issue not found");
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
expect(mockIssueService.getComment).not.toHaveBeenCalled();
});
it("allows visible issue field updates for peer agents", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment" || input.action === "issue:mutate" || input.action === "issue:read",
action: input.action,
reason:
input.action === "issue:comment"
? "allow_issue_mention_grant"
: input.action === "issue:mutate"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation:
input.action === "issue:comment"
? "Allowed by a mention-scoped issue comment grant."
: input.action === "issue:mutate"
? "Allowed by test boundary default."
: "Missing permission.",
}));
const res = await request(await createApp(peerActor()))
.patch(`/api/issues/${issueId}`)
.send({ status: "done" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ status: "done" }),
expect.anything(),
undefined,
expect.any(Array),
);
});
it("denies cross-company agents before comment authorization is evaluated", async () => {
const res = await request(await createApp(peerActor({ companyId: "99999999-9999-4999-8999-999999999999" })))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "Wrong company." });
// Cross-tenant requests return 404 (not 403) so the response is
// indistinguishable from a nonexistent issue — no existence oracle.
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(res.body.error).toBe("Issue not found");
expect(mockAccessService.decide).not.toHaveBeenCalledWith(expect.objectContaining({ action: "issue:comment" }));
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects the checked-out owner without a run id on attachment upload (401)", async () => {
// Regression: an agent-authenticated client (e.g. the CLI's attachment:upload)
// that fails to send X-Paperclip-Run-Id must be rejected — mutating your own
// in-progress checkout requires proving run ownership.
const app = await createApp({
type: "agent",
agentId: ownerAgentId,
companyId,
source: "agent_key",
// intentionally no runId
});
const res = await request(app)
.post(`/api/companies/${companyId}/issues/${issueId}/attachments`)
.attach("file", Buffer.from("report"), { filename: "report.html", contentType: "text/html" });
expect(res.status, JSON.stringify(res.body)).toBe(401);
expect(res.body.error).toBe("Agent run id required");
expect(mockStorageService.putFile).not.toHaveBeenCalled();
});
it("allows the checked-out owner with the matching run id to patch and update documents", async () => {
const app = await createApp(ownerActor());
await request(app).patch(`/api/issues/${issueId}`).send({ title: "Updated" }).expect(200);
await request(app)
.put(`/api/issues/${issueId}/documents/plan`)
.send({ format: "markdown", body: "# updated" })
.expect(200);
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalledWith(
expect.objectContaining({
issueId,
key: "plan",
createdByAgentId: ownerAgentId,
createdByRunId: ownerRunId,
lockedDocumentStrategy: "create_new_document",
}),
);
});
it("adds the authenticated run diff summary to PR work products", async () => {
mockWorkProductService.latestRunDiffSummary.mockResolvedValue({
additions: 17,
deletions: 5,
changedFiles: 3,
});
const app = await createApp(ownerActor());
await request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "pull_request",
provider: "github",
title: "PR 42",
url: "https://github.com/paperclipai/paperclip/pull/42",
metadata: { repo: "paperclipai/paperclip", number: 42 },
}).expect(201);
expect(mockWorkProductService.latestRunDiffSummary).toHaveBeenCalledWith(ownerRunId);
expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith(
issueId,
companyId,
expect.objectContaining({
createdByRunId: ownerRunId,
metadata: expect.objectContaining({
additions: 17,
deletions: 5,
changedFiles: 3,
}),
}),
);
});
it("falls back to GitHub commit stats when the authenticated run has no diff event", async () => {
mockWorkProductService.resolveCommitDiffSummary.mockResolvedValue({
additions: 11,
deletions: 3,
changedFiles: 2,
});
const app = await createApp(ownerActor());
await request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "commit",
provider: "github",
title: "Commit 9c12ae7",
url: "https://github.com/paperclipai/paperclip/commit/9c12ae7b41e5",
metadata: { repo: "paperclipai/paperclip", sha: "9c12ae7b41e5" },
}).expect(201);
expect(mockWorkProductService.resolveCommitDiffSummary).toHaveBeenCalledWith(
companyId,
expect.objectContaining({ type: "commit", provider: "github" }),
);
expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith(
issueId,
companyId,
expect.objectContaining({
metadata: expect.objectContaining({ additions: 11, deletions: 3, changedFiles: 2 }),
}),
);
});
it("rejects agent-created work products with a forged run id", async () => {
const app = await createApp(ownerActor());
const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "artifact",
provider: "test",
title: "Artifact",
createdByRunId: "66666666-6666-4666-8666-666666666666",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("createdByRunId must match the authenticated agent run");
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
});
it("rejects work product updates with a forged agent run id", async () => {
const app = await createApp(ownerActor());
const res = await request(app).patch("/api/work-products/product-1").send({
createdByRunId: "66666666-6666-4666-8666-666666666666",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("createdByRunId must match the authenticated agent run");
expect(mockWorkProductService.update).not.toHaveBeenCalled();
});
it("rejects board-created work products with a foreign-company run id", async () => {
const app = await createApp(
boardActor(),
createRunContextDb({}, [{
id: "66666666-6666-4666-8666-666666666666",
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
agentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
agentCompanyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
contextSnapshot: {},
}]),
);
const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "artifact",
provider: "test",
title: "Artifact",
createdByRunId: "66666666-6666-4666-8666-666666666666",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("createdByRunId is not valid for this company");
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
});
it.each([
[
"work product create",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/work-products`).send({
type: "artifact",
provider: "test",
title: "Artifact",
}),
"Status-only recovery runs cannot update issue documents",
],
[
"work product update",
(app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" }),
"Status-only recovery runs cannot update issue documents",
],
[
"work product delete",
(app: express.Express) => request(app).delete("/api/work-products/product-1"),
"Status-only recovery runs cannot update issue documents",
],
[
"low-trust promotion",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/low-trust/promotions`).send({
sourceArtifactKind: "comment",
sourceArtifactId: recoveryActionId,
title: "Promoted artifact",
summary: "Sanitized output",
}),
"Status-only recovery runs cannot update issue documents",
],
[
"attachment upload",
(app: express.Express) =>
request(app)
.post(`/api/companies/${companyId}/issues/${issueId}/attachments`)
.attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }),
"Status-only recovery runs cannot update issue documents",
],
[
"attachment delete",
(app: express.Express) => request(app).delete("/api/attachments/attachment-1"),
"Status-only recovery runs cannot update issue documents",
],
[
"issue approval link",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/approvals`).send({
approvalId: "88888888-8888-4888-8888-888888888888",
}),
"Status-only recovery runs cannot create or modify approvals",
],
[
"issue approval unlink",
(app: express.Express) =>
request(app).delete(`/api/issues/${issueId}/approvals/88888888-8888-4888-8888-888888888888`),
"Status-only recovery runs cannot create or modify approvals",
],
])("blocks status-only recovery runs from %s", async (_name, sendRequest, expectedError) => {
const app = await createApp(
ownerActor(),
createRunContextDb({
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
}),
);
const res = await sendRequest(app);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain(expectedError);
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
expect(mockWorkProductService.update).not.toHaveBeenCalled();
expect(mockWorkProductService.remove).not.toHaveBeenCalled();
expect(mockStorageService.putFile).not.toHaveBeenCalled();
expect(mockStorageService.deleteObject).not.toHaveBeenCalled();
expect(mockIssueService.removeAttachment).not.toHaveBeenCalled();
expect(mockIssueApprovalService.link).not.toHaveBeenCalled();
expect(mockIssueApprovalService.unlink).not.toHaveBeenCalled();
});
it("defaults agent-created root follow-up issues to inherit the current run workspace", async () => {
mockProjectService.getById.mockResolvedValue({
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", companyId, visibility: "open",
} as never);
const app = await createApp(
ownerActor(),
createRunContextDb({
issueId,
executionWorkspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
}),
);
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Follow-up in same worktree",
projectId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
title: "Follow-up in same worktree",
inheritExecutionWorkspaceFromIssueId: issueId,
}),
);
});
it("authorizes child creation through the shared visible-issue write path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/children`)
.send({ title: "Peer-created child" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:mutate",
resource: expect.objectContaining({ issueId }),
}));
expect(mockIssueService.createChild).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ title: "Peer-created child" }),
);
});
it("preserves explicit workspace choices on agent-created root issues", async () => {
const app = await createApp(
ownerActor(),
createRunContextDb({
issueId,
executionWorkspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
}),
);
const explicitExecutionWorkspaceId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Explicit different workspace",
executionWorkspaceId: explicitExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
title: "Explicit different workspace",
executionWorkspaceId: explicitExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
}),
);
expect(mockIssueService.create).toHaveBeenCalledWith(
companyId,
expect.not.objectContaining({
inheritExecutionWorkspaceFromIssueId: issueId,
}),
);
});
it("rejects agent-created issues that supply responsibleUserId", async () => {
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Spoof responsible user",
responsibleUserId: "spoofed-user",
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.error).toContain("responsibleUserId");
expect(mockIssueService.create).not.toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId,
actorType: "agent",
actorId: ownerAgentId,
action: "issue.attribution_spoof_rejected",
entityType: "company",
details: expect.objectContaining({
surface: "issues.create",
field: "responsibleUserId",
requestedValue: "spoofed-user",
}),
}),
);
});
it("strips agent-supplied createdByUserId and derives attribution from the authenticated actor", async () => {
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Spoof creator",
createdByUserId: "spoofed-user",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
title: "Spoof creator",
createdByAgentId: ownerAgentId,
createdByUserId: null,
actorRunId: ownerRunId,
}),
);
expect(mockIssueService.create).toHaveBeenCalledWith(
companyId,
expect.not.objectContaining({
createdByUserId: "spoofed-user",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId,
actorType: "agent",
actorId: ownerAgentId,
action: "issue.attribution_spoof_stripped",
details: expect.objectContaining({
surface: "issues.create",
field: "createdByUserId",
requestedValue: "spoofed-user",
}),
}),
);
});
it("allows board-created issues to pass explicit responsibleUserId as trusted attribution", async () => {
const app = await createApp(boardActor());
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Board-owned work",
responsibleUserId: "responsible-board-user",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
title: "Board-owned work",
responsibleUserId: "responsible-board-user",
createdByUserId: "board-user",
trustExplicitResponsibleUserId: true,
}),
);
});
it("rejects agent-created child issues that supply responsibleUserId", async () => {
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/children`)
.send({
title: "Spoof child responsible user",
responsibleUserId: "spoofed-user",
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(mockIssueService.createChild).not.toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId,
action: "issue.attribution_spoof_rejected",
entityType: "issue",
entityId: issueId,
details: expect.objectContaining({
surface: "issues.children.create",
field: "responsibleUserId",
}),
}),
);
});
it("rejects accepted-plan child creation when an agent child body supplies responsibleUserId", async () => {
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/accepted-plan-decompositions`)
.send({
acceptedPlanRevisionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
children: [
{
title: "Spoof plan child responsible user",
responsibleUserId: "spoofed-user",
},
],
});
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(mockIssueService.decomposeAcceptedPlan).not.toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId,
action: "issue.attribution_spoof_rejected",
entityType: "issue",
entityId: issueId,
details: expect.objectContaining({
surface: "issues.accepted_plan_decomposition",
field: "responsibleUserId",
}),
}),
);
});
it("rejects retired issue assignee profile overrides", async () => {
const app = await createApp(boardActor());
await request(app)
.patch(`/api/issues/${issueId}`)
.send({ assigneeAdapterOverrides: { modelProfile: "cheap" } })
.expect(400);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("preserves committed issue updates, comments, documents, and work product writes when recovery revalidation fails", async () => {
const app = await createApp(ownerActor());
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.patch(`/api/issues/${issueId}`)
.send({ title: "Updated after commit" })
.expect(200);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.post(`/api/issues/${issueId}/comments`)
.send({ body: "progress update" })
.expect(201);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.put(`/api/issues/${issueId}/documents/plan`)
.send({ format: "markdown", body: "# updated" })
.expect(200);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.patch("/api/work-products/product-1")
.send({ title: "Updated product" })
.expect(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ title: "Updated after commit" }),
);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"progress update",
expect.any(Object),
expect.any(Object),
expect.any(Object),
);
expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled();
expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" });
});
it("preserves board mutations on active checkouts", async () => {
const app = await createApp(boardActor());
await request(app).patch(`/api/issues/${issueId}`).send({ title: "Board update" }).expect(200);
await request(app)
.put(`/api/issues/${issueId}/documents/plan`)
.send({ format: "markdown", body: "# board" })
.expect(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled();
});
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts" || input.action === "issue:read",
action: input.action,
reason:
input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation:
input.action === "tasks:manage_active_checkouts"
? "Allowed by checkout management grant."
: input.action === "issue:mutate"
? "Allowed by test boundary default."
: "Missing permission.",
}));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).toHaveBeenCalled();
});
it.each([
["todo", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Todo update" })],
["blocked", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked update" })],
])("allows peer agent %s issue %s updates outside active checkout ownership", async (status, _kind, sendRequest) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: status as "todo" | "blocked", assigneeAgentId: ownerAgentId }));
const res = await sendRequest(await createApp(peerActor()));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it.each([
["done", "todo", 403, "Agent cannot request follow-up for another agent's issue"],
["cancelled", "todo", 409, "Cancelled issues must be restored through the dedicated restore flow"],
["blocked", "done", 403, "Agent cannot request follow-up for another agent's issue"],
])(
"rejects peer agent direct status transitions from %s to %s",
async (status, nextStatus, expectedStatus, expectedError) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status, assigneeAgentId: ownerAgentId }));
const res = await request(await createApp(peerActor()))
.patch(`/api/issues/${issueId}`)
.send({ status: nextStatus });
expect(res.status, JSON.stringify(res.body)).toBe(expectedStatus);
expect(res.body.error).toBe(expectedError);
expect(mockIssueService.update).not.toHaveBeenCalled();
},
);
it("allows same-company agent mutations on unassigned in-progress issues", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: null }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: null }),
...patch,
}));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(res.body).toMatchObject({
id: issueId,
assigneeAgentId: null,
title: "Claimable update",
});
});
it.each([
["board", "board"],
["a company user", { userId: "board-user" }],
])("rejects an agent naming %s as unblock owner", async (_label, unblockOwner) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress" }));
const res = await request(await createApp(ownerActor())).patch(`/api/issues/${issueId}`).send({
status: "blocked",
unblockDescriptor: { owner: unblockOwner, action: "Review the blocker" },
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agents may only name themselves as an unblock owner");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it.each([
["board", "board"],
["a company user", { userId: "board-user" }],
])("rejects an agent changing an already-blocked issue owner to %s", async (_label, unblockOwner) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked" }));
const res = await request(await createApp(ownerActor())).patch(`/api/issues/${issueId}`).send({
unblockDescriptor: { owner: unblockOwner, action: "Review the blocker" },
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agents may only name themselves as an unblock owner");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("allows a board actor to name the board as unblock owner", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress" }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ status: "in_progress" }),
...patch,
}));
const res = await request(await createApp(boardActor())).patch(`/api/issues/${issueId}`).send({
status: "blocked",
unblockDescriptor: { owner: "board", action: "Review the blocker" },
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({
status: "blocked",
unblockDescriptor: { owner: "board", action: "Review the blocker" },
}),
);
});
it.each(["bare blocked update", "human-owned inbox fixture"] as const)(
"applies the production blocked contract to the %s", async kind => {
const actor = boardActor();
const existing = makeIssue({ status: "in_progress", assigneeAgentId: null, assigneeUserId: actor.userId });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({ ...existing, ...patch }));
const db = createRunContextDb({}, ownerAgentId, ownerRunId, [], false, { userId: actor.userId });
const payload = kind === "bare blocked update" ? { status: "blocked" } : inboxBlockedActivity(actor.userId);
const res = await request(await createApp(actor, db)).patch(`/api/issues/${issueId}`).send(payload);
if (kind === "bare blocked update") {
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.error).toBe("Entering blocked requires unresolved blockers, a pending interaction/approval, or unblockDescriptor");
expect(mockIssueService.update).not.toHaveBeenCalled();
} else {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({ status: "blocked", unblockDescriptor: {
owner: { userId: actor.userId }, action: "Review the QA inbox task before continuing.",
} });
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining(payload));
}
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
},
);
it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ status: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects peer-agent recovery resolution on a board-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action");
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
});
it("rejects the named recovery owner completing a board-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details?.code).toBe("recovery_source_authority_required");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
});
it("rejects a recovery owner completing an independently agent-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details?.code).toBe("recovery_source_authority_required");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it.each(["done", "cancelled"])(
"rejects recovery-owner PATCH of an agent-owned source to %s",
async (status) => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
});
const res = await request(await createApp(peerActor()))
.patch(`/api/issues/${issueId}`)
.send({ status });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details?.code).toBe("recovery_source_authority_required");
expect(mockIssueService.update).not.toHaveBeenCalled();
},
);
it("rejects recovery-owner reassignment of an independently agent-owned source", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
});
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent(peerAgentId),
});
const res = await request(await createApp(peerActor()))
.patch(`/api/issues/${issueId}`)
.send({ assigneeAgentId: peerAgentId });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details?.code).toBe("recovery_source_authority_required");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects a recovery owner who is not the current governed review participant", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({
status: "in_review",
assigneeAgentId: ownerAgentId,
executionState: {
status: "pending",
currentStageId: "88888888-8888-4888-8888-888888888888",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: ownerAgentId },
returnAssignee: { type: "agent", agentId: ownerAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details?.code).toBe("recovery_source_authority_required");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("keeps configured review policy authoritative during recovery resolution", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({
status: "in_review",
assigneeAgentId: ownerAgentId,
reviewPolicy: "human_only",
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details?.code).toBe("review_policy_denied");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("allows a recovery owner to record a receipt without mutating a board-owned source", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "board-user",
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalled();
});
it("wakes the assigned agent when recovery resolution restores a source issue to todo", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
returnOwnerAgentId: ownerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "todo",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.not.objectContaining({ assigneeAgentId: expect.anything() }),
expect.anything(),
expect.any(Array),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId,
recoveryActionId,
mutation: "recovery_action_resolution",
}),
}),
);
});
it("resumes a verified stopped workspace owner without waking the provider", async () => {
mockAccessService.decide.mockResolvedValue({ allowed: true });
mockResumeWorkspaceFinalization.mockResolvedValue({ runId: ownerRunId, status: "queued" });
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." });
expect(res.status).toBe(202);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "runtime:confirm_owner_stop" }));
expect(mockResumeWorkspaceFinalization).toHaveBeenCalledWith(expect.objectContaining({ companyId, issueId, runId: ownerRunId, ownerToken: ownerRunId, actorId: "board-user" }));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each([{ controllerAndCopybackStopped: false }, { stopEvidence: "" }])("requires explicit stop evidence: %j", async invalid => {
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited.", ...invalid });
expect(res.status).toBe(400);
expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled();
});
it.each(["agent", "viewer"])("rejects %s workspace-owner release before admission", async kind => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:read", explanation: "No runtime access" }));
const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
controllerAndCopybackStopped: true, stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." });
expect(res.status).toBe(403);
expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled();
});
it("rejects an ordinary runtime manager's claimed foreign-owner stop", async () => {
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
allowed: action === "issue:read" || action === "runtime:manage",
explanation: "A deployment administrator must verify the controller stopped",
}));
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/resume-workspace-finalization`)
.send({ actionId: recoveryActionId, runId: ownerRunId, ownerToken: ownerRunId,
controllerAndCopybackStopped: true, stopEvidence: "I claim that the foreign controller and all its processes have stopped." });
expect(res.status).toBe(403);
expect(mockResumeWorkspaceFinalization).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("queues board export-only recovery without calling provider wake", async () => {
mockAccessService.decide.mockResolvedValue({ allowed: true });
mockRetryWorkspaceExport.mockResolvedValue({ runId: ownerRunId, status: "queued" });
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/retry-workspace-export`)
.send({ actionId: recoveryActionId, runId: ownerRunId, repairNote: "Restored provider connectivity and preserved all saved files." });
expect(res.status).toBe(202);
expect(mockRetryWorkspaceExport).toHaveBeenCalledWith(expect.objectContaining({ companyId, issueId, runId: ownerRunId, actionId: recoveryActionId, actorId: "board-user" }));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("lets a task editor request safe recovery without runtime certification", async () => {
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({ allowed: ["issue:read", "issue:mutate"].includes(action) }));
mockRequestWorkspaceRecovery.mockResolvedValue({ actionId: recoveryActionId, status: "queued" });
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/recover-workspace`)
.send({ actionId: recoveryActionId, runId: ownerRunId, idempotencyKey: ownerRunId });
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockRequestWorkspaceRecovery).toHaveBeenCalledWith(expect.objectContaining({ companyId, issueId,
actionId: recoveryActionId, runId: ownerRunId, idempotencyKey: ownerRunId, actorId: "board-user" }));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each(["agent", "viewer", "certification"])("rejects an unauthorized or fabricated recovery request: %s", async kind => {
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
allowed: action === "issue:read" || (kind === "certification" && action === "issue:mutate"), explanation: "Read-only task access",
}));
const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/recover-workspace`)
.send({ actionId: recoveryActionId, runId: ownerRunId, idempotencyKey: ownerRunId,
...(kind === "certification" ? { providerStopped: true, workspaceRepairEvidence: "I claim the missing files were repaired." } : {}) });
expect(res.status).toBe(kind === "certification" ? 400 : 403);
expect(mockRequestWorkspaceRecovery).not.toHaveBeenCalled();
});
it.each(["agent", "viewer"])("rejects %s export-only retries before admission", async kind => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read", explanation: "The viewer can read but has no runtime access",
}));
const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/retry-workspace-export`)
.send({ actionId: recoveryActionId, runId: ownerRunId, repairNote: "Restored provider connectivity and preserved all saved files." });
expect(res.status).toBe(403); expect(mockRetryWorkspaceExport).not.toHaveBeenCalled();
});
it.each(["done", "in_review"])("does not bypass unverified copyback via false_positive to %s", async sourceIssueStatus => {
const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId });
mockIssueService.getById.mockResolvedValue(sourceIssue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({ ...sourceIssue, ...patch }));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId, status: "active", kind: "active_run_watchdog", ownerType: "board",
ownerAgentId: null, returnOwnerAgentId: ownerAgentId,
cause: "native_workspace_finalization_owner_unverified", evidence: { runId: ownerRunId },
});
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "false_positive", sourceIssueStatus });
expect(res.status).toBe(409);
expect(res.body.details?.code).toBe("workspace_owner_stop_required");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each(["todo", "done", "in_review"].flatMap(sourceIssueStatus => ["native_workspace_sync_out_unsafe_archive", "native_workspace_sync_out_retry_exhausted", "native_workspace_finalization_owner_unverified"].map(cause => ({ sourceIssueStatus, cause }))))("does not resolve accepted export recovery through an ordinary $sourceIssueStatus transition: $cause", async ({ sourceIssueStatus, cause }) => {
const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId });
mockIssueService.getById.mockResolvedValue(sourceIssue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({ ...sourceIssue, ...patch }));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId, status: "active", kind: "active_run_watchdog", ownerType: "board",
ownerAgentId: null, returnOwnerAgentId: ownerAgentId,
cause, evidence: { runId: ownerRunId },
});
const res = await request(await createApp(boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus });
expect.soft(res.status).toBe(409);
expect.soft(res.body.details?.code).toBe(cause === "native_workspace_sync_out_unsafe_archive" ? "workspace_export_automatic_recovery" : cause === "native_workspace_finalization_owner_unverified" ? "workspace_owner_stop_required" : "workspace_export_retry_required");
expect.soft(mockIssueService.update).not.toHaveBeenCalled();
expect.soft(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each(["active", "waiting", "completed", "unavailable", "endpoint_removed"])(
"rejects restoring a %s chat task before changing its issue or recovery action",
async (state) => {
const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId });
mockIssueService.getById.mockResolvedValue(sourceIssue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...sourceIssue,
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
status: "active",
ownerType: "board",
ownerAgentId: null,
returnOwnerAgentId: ownerAgentId,
evidence: { runId: ownerRunId },
});
const db = createRunContextDb({}, ownerAgentId, ownerRunId, [{
id: "88888888-8888-4888-8888-888888888888",
companyId,
issueId,
state,
}]);
const res = await request(await createApp(boardActor(), db))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" });
expect.soft(res.status).toBe(409);
expect.soft(res.body.details?.code).toBe("chat_recovery_requires_authorized_context");
expect.soft(mockIssueService.update).not.toHaveBeenCalled();
expect.soft(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
expect.soft(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect.soft(mockLogActivity).not.toHaveBeenCalled();
// This is a route-boundary test, not a fake SQL engine: inspect the
// actual compiled predicate so a global or active-only query cannot pass.
// Only restricted chat bindings use this recovery gate; email uses normal agent work.
expect(db.chatBindingQueries).toHaveLength(1);
expect(db.chatBindingQueries[0].sql).toBe(
'("chat_endpoints"."external_execution_policy" = $1 and "chat_conversations"."company_id" = $2 and "chat_conversations"."issue_id" = $3)',
);
expect(db.chatBindingQueries[0].params).toEqual(["restricted", companyId, issueId]);
},
);
describe("exact chat recovery retry", () => {
const chatRetryActionId = "99999999-9999-4999-8999-999999999999";
const retryRequest = {
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "todo",
};
function setupChatRecovery(overrides: Record<string, unknown> = {}) {
const sourceIssue = makeIssue({
status: "blocked",
assigneeAgentId: ownerAgentId,
});
mockIssueService.getById.mockResolvedValue(sourceIssue);
mockIssueService.update.mockImplementation(
async (_id: string, patch: Record<string, unknown>) => ({
...sourceIssue,
...patch,
}),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
status: "active",
ownerType: "board",
ownerAgentId: null,
returnOwnerAgentId: ownerAgentId,
evidence: { runId: ownerRunId },
...overrides,
});
const db = createRunContextDb({}, ownerAgentId, ownerRunId, [
{
id: "88888888-8888-4888-8888-888888888888",
companyId,
issueId,
state: "active",
},
]);
const order: string[] = [];
const transaction = db.transaction;
db.transaction = async (callback) => {
order.push("begin");
try {
const result = await transaction(callback);
order.push("commit");
return result;
} catch (error) {
order.push("rollback");
throw error;
}
};
mockChatRunRetries.prepareFailedChatRunRetry.mockImplementation(
async () => {
order.push("stage");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).not.toHaveBeenCalled();
return { actionId: chatRetryActionId, issueId };
},
);
mockChatRunRetries.processFailedChatRunRetry.mockImplementation(
async () => {
order.push("dispatch");
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).toHaveBeenCalledTimes(1);
return {
actionId: chatRetryActionId,
issueId,
runId: null,
status: "deferred",
};
},
);
return { db, order, sourceIssue };
}
it("stages immutable recovery evidence before resolution and accepts deferred dispatch", async () => {
const { db, order } = setupChatRecovery();
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send(retryRequest);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.issue).toMatchObject({
id: issueId,
status: "todo",
activeRecoveryAction: null,
});
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).toHaveBeenCalledExactlyOnceWith(db, {
companyId,
issueId,
agentId: ownerAgentId,
failedRunId: ownerRunId,
initiatedByUserId: "board-user",
});
expect(mockIssueService.update).toHaveBeenCalledExactlyOnceWith(
issueId,
expect.objectContaining({ status: "todo" }),
db,
expect.any(Array),
);
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
companyId,
sourceIssueId: issueId,
actionId: recoveryActionId,
}),
db,
);
expect(
mockChatRunRetries.processFailedChatRunRetry,
).toHaveBeenCalledExactlyOnceWith(chatRetryActionId);
expect(order).toEqual(["begin", "stage", "commit", "dispatch"]);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(db.chatBindingQueries[0].params).toEqual(["restricted", companyId, issueId]);
});
it("keeps committed recovery resolution successful when immediate dispatch rejects", async () => {
const { db, order } = setupChatRecovery();
mockChatRunRetries.processFailedChatRunRetry.mockImplementation(
async () => {
order.push("dispatch");
throw new Error("PRIVATE immediate dispatch failure");
},
);
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send(retryRequest);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.issue).toMatchObject({
id: issueId,
status: "todo",
activeRecoveryAction: null,
});
expect(res.body.recoveryAction).toMatchObject({
id: recoveryActionId,
status: "resolved",
});
expect(order).toEqual(["begin", "stage", "commit", "dispatch"]);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).toHaveBeenCalledTimes(1);
expect(
mockChatRunRetries.processFailedChatRunRetry,
).toHaveBeenCalledExactlyOnceWith(chatRetryActionId);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(res.text).not.toContain("PRIVATE");
});
it("does not mutate issue, recovery action, or wake after exact source authorization refuses", async () => {
const { db, order } = setupChatRecovery();
const { HttpError: CurrentHttpError } =
await vi.importActual<typeof import("../errors.js")>("../errors.js");
mockChatRunRetries.prepareFailedChatRunRetry.mockRejectedValue(
new CurrentHttpError(
409,
"The original conversation generation is retired",
{ code: "chat_retry_source_denied" },
),
);
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send(retryRequest);
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe("chat_retry_source_denied");
expect(order).toEqual(["begin", "rollback"]);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).not.toHaveBeenCalled();
expect(
mockChatRunRetries.processFailedChatRunRetry,
).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("does not dispatch staged retry if the recovery-resolution transaction aborts", async () => {
const { db, order } = setupChatRecovery();
mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue(
null,
);
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send(retryRequest);
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).toHaveBeenCalledTimes(1);
expect(order).toEqual(["begin", "stage", "rollback"]);
// The real DB owns rollback of prepared intent and issue changes. This
// route stub proves both writes share its transaction and no worker ran.
expect(mockIssueService.update.mock.calls[0]?.[2]).toBe(db);
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).toHaveBeenCalledExactlyOnceWith(expect.any(Object), db);
expect(
mockChatRunRetries.processFailedChatRunRetry,
).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.each([undefined, {}, { runId: null }, { runId: "not-a-run-id" }])(
"rejects missing or invalid server recovery evidence: %j",
async (evidence) => {
const { db } = setupChatRecovery({ evidence });
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send(retryRequest);
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe(
"chat_recovery_requires_authorized_context",
);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
},
);
it.each([
{ failedRunId: peerAgentId },
{
payload: {
issueId: peerAgentId,
wakeCommentId: "forged",
taskKey: "forged",
},
},
{ initiatedByUserId: "forged-user" },
])(
"rejects caller-supplied retry context before resolution: %j",
async (override) => {
const { db } = setupChatRecovery();
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ ...retryRequest, ...override });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
},
);
it("rejects a stale recovery action selector before retry staging", async () => {
const { db } = setupChatRecovery();
const res = await request(
await createApp(boardActor(), db, {
chatRunRetries: mockChatRunRetries,
}),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ ...retryRequest, actionId: peerAgentId });
expect(res.status).toBe(404);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each(["agent", "company", "issue-read"])(
"denies %s authority before exact retry staging",
async (denial) => {
const { db } = setupChatRecovery();
const actor =
denial === "agent"
? ownerActor()
: {
...boardActor(),
source: "session",
companyIds: denial === "company" ? [] : [companyId],
};
if (denial === "issue-read")
mockAccessService.decide.mockResolvedValue({
allowed: false,
reason: "deny_policy_restricted",
explanation: "Issue read denied",
});
const res = await request(
await createApp(actor, db, { chatRunRetries: mockChatRunRetries }),
)
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send(retryRequest);
if (denial === "agent") {
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe(
"chat_recovery_requires_authorized_context",
);
} else {
expect([403, 404]).toContain(res.status);
}
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(
mockIssueRecoveryActionService.resolveActiveForIssue,
).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
},
);
});
describe("repairing an unavailable starting branch", () => {
const repair = { actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo",
workspaceBaseRef: { requestedRef: "main", branch: "master" } };
function seedBranchRepair() {
mockAccessService.decide.mockResolvedValue({ allowed: true, reason: "allow_explicit_grant", explanation: "Allowed" });
const issue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId,
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "task/{issueId}" } } });
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id, patch) => ({ ...issue, ...patch }));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId,
status: "active", cause: "configuration_incomplete", ownerType: "board", returnOwnerAgentId: ownerAgentId,
evidence: { latestRunId: ownerRunId } });
return createRunContextDb({}, [{ id: ownerRunId, companyId, agentId: ownerAgentId,
contextSnapshot: { issueId }, errorCode: "configuration_incomplete",
resultJson: { configurationIncomplete: { reason: "workspace_base_ref_unresolved", requestedRef: "main" } } }]);
}
it("saves the task-only branch in the recovery transaction and requests a retry", async () => {
const db = seedBranchRepair();
const res = await request(await createApp(boardActor(), db)).post(`/api/issues/${issueId}/recovery-actions/resolve`).send(repair);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({ status: "todo",
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "master", branchTemplate: "task/{issueId}" } },
}), expect.anything(), expect.anything());
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
});
it("creates a valid task override when the failing branch was inherited", async () => {
const db = seedBranchRepair();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, executionWorkspaceSettings: null }));
const res = await request(await createApp(boardActor(), db)).post(`/api/issues/${issueId}/recovery-actions/resolve`).send(repair);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({
executionWorkspaceSettings: { workspaceStrategy: { type: "git_worktree", baseRef: "master" } },
}), expect.anything(), expect.anything());
});
it("preserves inherited agent setup when repairing only the base branch", async () => {
const db = seedBranchRepair();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, executionWorkspaceSettings: null }));
const strategy = { type: "git_worktree", baseRef: "main", provisionCommand: "npm run setup", branchTemplate: "task/{{issue.identifier}}", worktreeParentDir: ".worktrees" };
mockAgentService.getById.mockResolvedValue({ ...makeAgent(ownerAgentId), adapterConfig: { workspaceStrategy: strategy } });
const res = await request(await createApp(boardActor(), db)).post(`/api/issues/${issueId}/recovery-actions/resolve`).send(repair);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({
executionWorkspaceSettings: { workspaceStrategy: { ...strategy, baseRef: "master" } },
}), expect.anything(), expect.anything());
expect(strategy.baseRef).toBe("main");
});
it.each(["stale", "project", "strategy", "agent", "permission", "paused", "run", "approval", "budget", "blocker"])("rejects %s repairs without saving or waking", async gate => {
const db = seedBranchRepair();
if (gate === "project") mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, projectId: "new-project" }));
if (gate === "strategy") mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, executionWorkspaceSettings: { workspaceStrategy: { type: "adapter_managed" } } }));
if (gate === "permission") mockAccessService.decide.mockImplementation(async ({ action }) => ({ allowed: action !== "runtime:manage", reason: "deny_missing_grant", explanation: "No runtime permission" }));
if (gate === "stale") mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId,
executionWorkspaceSettings: { workspaceStrategy: { baseRef: "release" } } }));
if (gate === "paused") mockAgentService.getById.mockResolvedValue({ ...makeAgent(ownerAgentId), status: "paused" });
if (gate === "run") mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, executionRunId: ownerRunId }));
if (gate === "approval") mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([{ status: "pending" }]);
if (gate === "budget") mockBudgetService.getInvocationBlock.mockResolvedValue({ scope: "agent", reason: "hard_limit_reached" });
if (gate === "blocker") mockIssueService.getDependencyReadiness.mockResolvedValue({ unresolvedBlockerCount: 1 });
const res = await request(await createApp(gate === "agent" ? ownerActor() : boardActor(), db)).post(`/api/issues/${issueId}/recovery-actions/resolve`).send(repair);
expect([403, 409], JSON.stringify(res.body)).toContain(res.status);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
});
describe("retrying an escalated disposition repair", () => {
function seedRetry() {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId, status: "active", kind: "deliberate_wait_without_target",
ownerType: "board", ownerAgentId: null, returnOwnerAgentId: ownerAgentId,
wakePolicy: { type: "board_escalation" },
});
}
it.each(["done", "cancelled", "backlog", "todo", "in_progress"])("rejects a stale retry of a %s task", async status => {
seedRetry(); mockIssueService.getById.mockResolvedValue(makeIssue({ status, assigneeAgentId: ownerAgentId }));
const res = await request(await createApp(boardActor())).post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe("disposition_recovery_retry_stale");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each(["owner", "budget", "approval", "pause", "run", "blocker", "pausedAgent", "terminatedAgent"])("keeps the %s gate authoritative for a board retry", async gate => {
seedRetry();
if (gate === "owner") mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: peerAgentId }));
if (gate === "budget") mockBudgetService.getInvocationBlock.mockResolvedValue({ scope: "agent", reason: "hard_limit_reached" });
if (gate === "approval") mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([{ status: "pending" }]);
if (gate === "pause") mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue({ holdId: "hold", mode: "pause" });
if (gate === "run") mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, executionRunId: ownerRunId }));
if (gate === "blocker") mockIssueService.getDependencyReadiness.mockResolvedValue({ unresolvedBlockerCount: 1 });
if (gate === "pausedAgent" || gate === "terminatedAgent") mockAgentService.getById.mockResolvedValue({ ...makeAgent(ownerAgentId), status: gate === "pausedAgent" ? "paused" : "terminated" });
const res = await request(await createApp(boardActor())).post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
});
it.each(["done", "in_review"])(
"keeps non-retry %s recovery resolution available for chat tasks",
async (sourceIssueStatus) => {
const sourceIssue = makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId });
mockIssueService.getById.mockResolvedValue(sourceIssue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...sourceIssue,
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
status: "active",
ownerType: "board",
ownerAgentId: null,
returnOwnerAgentId: ownerAgentId,
});
const db = createRunContextDb({}, ownerAgentId, ownerRunId, [{
id: "88888888-8888-4888-8888-888888888888",
companyId,
issueId,
state: "completed",
}]);
const res = await request(await createApp(boardActor(), db))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(db.chatBindingQueries).toEqual([]);
},
);
it.each([
["checkoutRunId", ownerRunId],
["executionRunId", ownerRunId],
])("blocks safe hand-back while the source has an active %s", async (lockField, lockRunId) => {
mockIssueService.getById.mockResolvedValue(makeIssue({
status: "blocked",
assigneeAgentId: ownerAgentId,
[lockField]: lockRunId,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
returnOwnerAgentId: ownerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe("recovery_source_run_lock");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("blocks safe hand-back while the original owner's budget is paused", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
returnOwnerAgentId: ownerAgentId,
});
mockBudgetService.getInvocationBlock.mockResolvedValue({
scope: "agent",
reason: "hard_limit_reached",
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe("recovery_safe_hand_back_budget_blocked");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("blocks safe hand-back while a governed approval remains pending", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId: peerAgentId,
returnOwnerAgentId: ownerAgentId,
});
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([{ status: "pending" }]);
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details?.code).toBe("recovery_governed_approval_pending");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("uses the authorization decision path for assignment changes", async () => {
const decide = vi.fn(async (_input: { action: string }) => ({
allowed: false,
action: "tasks:assign",
reason: "deny_policy_restricted",
explanation: "Target agent requires approval before task assignment.",
}));
decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read" || input.action === "issue:mutate",
action: input.action,
reason:
input.action === "issue:read" || input.action === "issue:mutate"
? "allow_self"
: "deny_policy_restricted",
explanation:
input.action === "issue:read" || input.action === "issue:mutate"
? "Allowed because the actor owns the assigned issue."
: "Target agent requires approval before task assignment.",
}));
(mockAccessService as any).decide = decide;
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent(peerAgentId),
});
const app = await createApp(ownerActor());
const res = await request(app)
.patch(`/api/issues/${issueId}`)
.send({ assigneeAgentId: peerAgentId });
expect(res.status).toBe(403);
expect(res.body.error).toContain("requires approval");
expect(decide).toHaveBeenCalledWith(expect.objectContaining({
action: "tasks:assign",
resource: expect.objectContaining({
type: "issue",
companyId,
issueId,
assigneeAgentId: peerAgentId,
}),
}));
expect(mockIssueService.update).not.toHaveBeenCalled();
});
describe("human self-handoff", () => {
const runningRun = {
id: ownerRunId,
companyId,
agentId: ownerAgentId,
runtimeMode: "legacy",
status: "running",
contextSnapshot: { issueId },
};
const handoff = {
assigneeAgentId: null,
assigneeUserId: "board-user",
status: "in_review",
};
const handoffApp = (actor: Record<string, unknown>) => createApp(actor,
createRunContextDb({}, ownerAgentId, ownerRunId, [], true));
beforeEach(() => {
mockIssueService.getById.mockResolvedValue(makeIssue({
status: "in_review",
executionRunId: ownerRunId,
}));
mockHeartbeatService.getRun.mockResolvedValue(runningRun);
mockHeartbeatService.cancelRun.mockResolvedValue({ ...runningRun, status: "cancelled" });
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, id: string) => ({
ambiguous: false,
agent: await mockAgentService.getById(id),
}));
});
it.each(["in_progress", "in_review"])("persists a human handoff from %s without waiting for its own slow shutdown", async status => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status, executionRunId: ownerRunId }));
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified."));
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
.patch(`/api/issues/${issueId}`).send(handoff);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject(handoff);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({
...handoff,
actorRunId: ownerRunId,
actorRunStopId: null,
});
expect(mockLogActivity.mock.calls.find(call => call[1].action === "issue.updated")?.[1]).toMatchObject({
action: "issue.updated",
runId: ownerRunId,
details: handoff,
});
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("recognizes the caller when the active run is resolved without an issue execution lock", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review" }));
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(runningRun);
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
.patch(`/api/issues/${issueId}`).send(handoff);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject(handoff);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it.each(["edit", "complete", "reclaim"])("rejects the outgoing run's later %s after handing the task to a human", async action => {
const app = handoffApp({ ...ownerActor(), source: "agent_jwt" });
const handedOff = await request(app).patch(`/api/issues/${issueId}`).send(handoff);
expect(handedOff.status, JSON.stringify(handedOff.body)).toBe(200);
mockIssueService.getById.mockResolvedValue(makeIssue(handoff));
mockIssueService.update.mockClear();
const res = await request(app).patch(`/api/issues/${issueId}`).send(
action === "edit" ? { title: "Late outgoing edit" } :
action === "complete" ? { status: "done" } :
{ assigneeAgentId: ownerAgentId, assigneeUserId: null },
);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details).toMatchObject({ code: "issue_handed_off" });
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("keeps a queued human response with its current agent", async () => {
const res = await request(createApp({ ...ownerActor(), source: "agent_jwt" }))
.patch(`/api/issues/${issueId}`).send(handoff);
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.code).toBe("interaction_response_queued");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("retains assignment permission checks for a different human recipient", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action !== "tasks:assign",
action: input.action,
reason: "deny_policy_restricted",
explanation: "Assignment requires approval.",
}));
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
.patch(`/api/issues/${issueId}`).send({ ...handoff, assigneeUserId: "another-user" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it.each(["board", "agent_key", "other_run", "native", "other_issue", "other_agent", "other_company"])(
"still requires a verified stop for %s reassignment", async scenario => {
let actor: Record<string, unknown> = { ...ownerActor(), source: "agent_jwt" };
if (scenario === "board") actor = boardActor();
if (scenario === "agent_key") actor = ownerActor();
const run = {
...runningRun,
...(scenario === "other_run" ? { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}),
...(scenario === "native" ? { runtimeMode: "native" } : {}),
...(scenario === "other_issue" ? { contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } } : {}),
...(scenario === "other_agent" ? { agentId: peerAgentId } : {}),
...(scenario === "other_company" ? { companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}),
};
mockHeartbeatService.getRun.mockResolvedValue(run);
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified."));
const res = await request(handoffApp(actor)).patch(`/api/issues/${issueId}`).send(handoff);
expect(res.status, JSON.stringify(res.body)).toBe(500);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(run.id, "Cancelled before issue reassignment",
expect.objectContaining({ errorCode: "issue_reassigned" }));
expect(mockIssueService.update).not.toHaveBeenCalled();
},
);
it.each(["agent", "unassigned"])("still stops the outgoing run before an %s handoff", async target => {
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
.patch(`/api/issues/${issueId}`).send({
assigneeAgentId: target === "agent" ? peerAgentId : null,
assigneeUserId: null,
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.cancelRun.mock.invocationCallOrder[0])
.toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!);
});
});
describe("native run changing its own task status", () => {
const nativeRun = {
id: ownerRunId,
companyId,
agentId: ownerAgentId,
runtimeMode: "native",
status: "running",
contextSnapshot: { issueId },
};
const runActor = () => ({ ...ownerActor(), source: "agent_jwt" });
beforeEach(() => {
mockIssueService.getById.mockResolvedValue(makeIssue({ executionRunId: ownerRunId, checkoutRunId: ownerRunId }));
mockHeartbeatService.getRun.mockResolvedValue(nativeRun);
});
it.each([
["in_review", {}],
["in_review", { assigneeAgentId: ownerAgentId }],
["done", {}],
["blocked", {}],
["todo", { blockedByIssueIds: [] }],
["blocked", { blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] }],
])("rejects %s before it releases the run's own execution lock (%j)", async (status, extra) => {
const res = await request(createApp(runActor())).patch(`/api/issues/${issueId}`).send({ status, ...extra });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details).toMatchObject({ code: "native_run_status_reported_by_finish", requestedStatus: status });
expect(res.body.error).toContain("paperclip_finish");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("still lets the run edit other fields of its task", async () => {
const res = await request(createApp(runActor())).patch(`/api/issues/${issueId}`).send({ title: "Renamed by its run" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
});
it.each(["in_review", "done"])("rejects recovery resolution changing its own task to %s", async status => {
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
evidence: { runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
});
const app = createApp(runActor());
const res = await request(app).post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: status });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.details).toMatchObject({ code: "native_run_status_reported_by_finish", requestedStatus: status });
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
const edit = await request(app).patch(`/api/issues/${issueId}`).send({ title: "Continue authorized work" });
expect(edit.status, JSON.stringify(edit.body)).toBe(200);
});
it.each(["native", "legacy", "board"])("preserves permitted %s recovery resolution", async kind => {
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId });
if (kind === "legacy") mockHeartbeatService.getRun.mockResolvedValue({ ...nativeRun, runtimeMode: "legacy" });
if (kind === "native") mockIssueService.getById.mockResolvedValue(makeIssue({
status: "in_review", executionRunId: ownerRunId, checkoutRunId: ownerRunId,
}));
const res = await request(createApp(kind === "board" ? boardActor() : runActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalledTimes(1);
expect(mockIssueService.update).toHaveBeenCalledTimes(kind === "native" ? 0 : 1);
});
it.each([
["a legacy run", { runtimeMode: "legacy" }, {}],
["a native run that no longer owns execution", {}, { executionRunId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", checkoutRunId: null }],
["a native run that is no longer running", { status: "failed" }, {}],
])("leaves the status change to the existing rules for %s", async (_label, runOverrides, issueOverrides) => {
mockHeartbeatService.getRun.mockResolvedValue({ ...nativeRun, ...runOverrides });
mockIssueService.getById.mockResolvedValue(makeIssue({ executionRunId: ownerRunId, checkoutRunId: ownerRunId, ...issueOverrides }));
const res = await request(createApp(runActor())).patch(`/api/issues/${issueId}`).send({ status: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({ status: "in_review" });
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ status: "in_review" });
});
it("does not intercept a native run handing its task to a human", async () => {
// A native handoff still stops the outgoing run first; confirm that stop
// so the request reaches the update instead of the stop-unconfirmed 409.
mockHeartbeatService.cancelRun.mockResolvedValue({ ...nativeRun, status: "cancelled" });
const handoff = { assigneeAgentId: null, assigneeUserId: "board-user", status: "in_review" };
const res = await request(createApp(runActor(), createRunContextDb({}, ownerAgentId, ownerRunId, [], true)))
.patch(`/api/issues/${issueId}`).send(handoff);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject(handoff);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(ownerRunId, expect.anything(), expect.anything());
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.cancelRun.mock.invocationCallOrder[0])
.toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!);
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject(handoff);
});
it("does not intercept a native conversation reply moving to review", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ executionRunId: ownerRunId, checkoutRunId: ownerRunId,
conversationAgentId: ownerAgentId, conversationUserId: "board-user" }));
const res = await request(createApp(runActor())).patch(`/api/issues/${issueId}`).send({ status: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({ status: "in_review" });
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ status: "in_review" });
});
it("keeps the dependency rule's error for a blocker it rejects", async () => {
const { HttpError: CurrentHttpError } = await vi.importActual<typeof import("../errors.js")>("../errors.js");
mockOnboardingFirstTaskDependenciesAllowed.mockRejectedValueOnce(
new CurrentHttpError(400, "Remove the onboarding task from blockedByIssueIds.", { code: "onboarding_first_task_parent_blocker" }));
const res = await request(createApp(runActor())).patch(`/api/issues/${issueId}`)
.send({ status: "blocked", blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(res.body.details).toMatchObject({ code: "onboarding_first_task_parent_blocker" });
expect(mockOnboardingFirstTaskDependenciesAllowed).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId, actorAgentId: ownerAgentId, actorRunId: ownerRunId, blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
}));
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("does not intercept a board user changing the status", async () => {
const res = await request(createApp(boardActor())).patch(`/api/issues/${issueId}`).send({ status: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({ status: "in_review" });
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ status: "in_review" });
});
});
describe("task watchdog scope grants", () => {
const watchdogRunId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab";
const watchdogReportIssueId = "cccccccc-cccc-4ccc-8ccc-cccccccccccd";
// The watchdog agent (peerAgentId) is NOT the assignee of the watched issue
// (ownerAgentId), so the base authorization boundary (issue:mutate) denies.
// The watchdog scope must grant the mutation regardless.
function watchdogActor(runId: string = watchdogRunId) {
return {
type: "agent",
agentId: peerAgentId,
companyId,
source: "agent_key",
runId,
};
}
function createWatchdogDb(options: {
watchedIssueId?: string;
watchdogIssueId?: string | null;
ancestryParentId?: string | null;
watchdogRows?: Record<string, unknown>[];
} = {}) {
const watchedIssueId = options.watchedIssueId ?? issueId;
const runRows = [{
id: watchdogRunId,
companyId,
agentId: peerAgentId,
contextSnapshot: { taskWatchdog: { watchedIssueId, stopFingerprint: "task_watchdog_stop:test" } },
}];
const watchdogRows = options.watchdogRows ?? [{
id: "dddddddd-dddd-4ddd-8ddd-ddddddddddde",
companyId,
issueId: watchedIssueId,
watchdogAgentId: peerAgentId,
watchdogIssueId: options.watchdogIssueId ?? watchdogReportIssueId,
status: "active",
}];
const ancestryRows = [{
id: "ancestry",
companyId,
parentId: options.ancestryParentId ?? null,
}];
const rowsForSelection = (selection: Record<string, unknown>) => {
const keys = Object.keys(selection);
if (keys.includes("entityId")) return [];
if (keys.includes("contextSnapshot")) return runRows;
if (keys.includes("watchdogAgentId")) return watchdogRows;
if (keys.includes("parentId")) return ancestryRows;
if (keys.includes("status")) return [];
if (keys.includes("agentCompanyId")) return runRows;
return [{ id: peerAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }];
};
const buildQuery = (selection: Record<string, unknown>) => {
const rows = rowsForSelection(selection);
const whereResult = {
orderBy: vi.fn(async () => []),
limit: vi.fn(() => ({
then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(rows),
})),
then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(rows),
};
const query = {
innerJoin: vi.fn(() => query),
where: vi.fn(() => whereResult),
};
return query;
};
return {
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn((table: Parameters<typeof getTableName>[0]) => getTableName(table) === "case_handoffs"
? emptyCaseHandoffQuery() : buildQuery(selection)),
})),
};
}
// The base boundary always denies a cross-agent issue:mutate; only the
// watchdog scope can widen access. Denying it here proves the grant works.
function denyBaseBoundary() {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "company_scope:read" || input.action === "issue:read" || input.action === "tasks:assign",
action: input.action,
reason:
input.action === "company_scope:read" || input.action === "issue:read" || input.action === "tasks:assign"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation: "Watchdog test boundary default.",
}));
}
it("lets a watchdog run comment on a watched issue assigned to a different agent", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).post(`/api/issues/${issueId}/comments`).send({ body: "Watchdog finding" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"Watchdog finding",
expect.any(Object),
expect.any(Object),
expect.any(Object),
);
});
it.each([
["in_progress"],
["blocked"],
["todo"],
])("lets a watchdog run transition a watched issue to %s", async (status) => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: ownerAgentId }),
...patch,
}));
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({ status }));
});
it("lets a watchdog run transition a watched issue to in_review with a live review path", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: ownerAgentId }),
...patch,
}));
// A pending interaction is a valid review path, so the agent in_review guard
// is satisfied — this isolates the test to the watchdog boundary grant.
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ status: "pending" }] as never);
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ status: "in_review" }),
expect.anything(),
);
});
it("rejects stale watchdog source mutations when revalidation finds a live path", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId }));
mockTaskWatchdogService.revalidateMutationScope.mockResolvedValueOnce({
allowed: false,
reason:
"Task-watchdog review is stale because the watched subtree now has a live, waiting, already-reviewed, or not-applicable path; refresh the source state before mutating it.",
classification: { state: "live", liveIssueIds: [issueId] },
});
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.error).toContain("Task-watchdog review is stale");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("suppresses watchdog follow-up creation when current source revalidation is live", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockTaskWatchdogService.revalidateMutationScope.mockResolvedValueOnce({
allowed: false,
reason:
"Task-watchdog review is stale because the watched subtree now has a live, waiting, already-reviewed, or not-applicable path; refresh the source state before mutating it.",
classification: { state: "live", liveIssueIds: [issueId] },
});
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app)
.post(`/api/issues/${issueId}/children`)
.send({ title: "Stale follow-up", status: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.error).toContain("Task-watchdog review is stale");
expect(mockIssueService.createChild).not.toHaveBeenCalled();
});
it("serializes watchdog accepted-plan follow-ups behind one active child lane", async () => {
denyBaseBoundary();
mockIssueService.list.mockResolvedValue([]);
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
ambiguous: false,
agent: reference === ownerAgentId ? makeAgent(ownerAgentId) : null,
}));
mockIssueService.getById.mockImplementation(async (id: string) => {
if (id === watchdogReportIssueId) {
return makeIssue({
id: watchdogReportIssueId,
originKind: "task_watchdog",
status: "in_progress",
assigneeAgentId: peerAgentId,
});
}
return makeIssue({ assigneeAgentId: ownerAgentId });
});
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app)
.post(`/api/issues/${issueId}/accepted-plan-decompositions`)
.send({
acceptedPlanRevisionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
children: [
{ title: "Fix watchdog authorization", assigneeAgentId: ownerAgentId },
{ title: "Fix watchdog startup race", assigneeAgentId: ownerAgentId },
],
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
const decompositionInput = mockIssueService.decomposeAcceptedPlan.mock.calls[0]?.[1];
const children = decompositionInput.children as Array<Record<string, unknown>>;
expect(children).toHaveLength(2);
expect(children[0]).toEqual(expect.objectContaining({
title: "Fix watchdog authorization",
status: "todo",
assigneeAgentId: ownerAgentId,
}));
expect(children[1]).toEqual(expect.objectContaining({
title: "Fix watchdog startup race",
status: "blocked",
assigneeAgentId: ownerAgentId,
blockedByIssueIds: [children[0]?.id],
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
payload: expect.objectContaining({ issueId: children[0]?.id }),
}),
);
expect(mockIssueService.update).toHaveBeenCalledWith(
watchdogReportIssueId,
expect.objectContaining({
status: "blocked",
blockedByIssueIds: [children[0]?.id],
actorAgentId: peerAgentId,
}),
);
});
it("preserves normal accepted-plan decomposition parallel wakeups outside watchdog context", async () => {
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
ambiguous: false,
agent: reference === ownerAgentId ? makeAgent(ownerAgentId) : null,
}));
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/accepted-plan-decompositions`)
.send({
acceptedPlanRevisionId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
children: [
{ title: "Implement backend", assigneeAgentId: ownerAgentId },
{ title: "Implement frontend", assigneeAgentId: ownerAgentId },
],
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
const decompositionInput = mockIssueService.decomposeAcceptedPlan.mock.calls[0]?.[1];
const children = decompositionInput.children as Array<Record<string, unknown>>;
expect(children).toHaveLength(2);
expect(children[0]).toEqual(expect.objectContaining({ status: "todo" }));
expect(children[1]).toEqual(expect.objectContaining({ status: "todo" }));
expect(children[1]?.blockedByIssueIds).toBeUndefined();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
1,
ownerAgentId,
expect.objectContaining({
payload: expect.objectContaining({ issueId: children[0]?.id }),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
2,
ownerAgentId,
expect.objectContaining({
payload: expect.objectContaining({ issueId: children[1]?.id }),
}),
);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
watchdogReportIssueId,
expect.anything(),
);
});
it("lets a watchdog run reassign a watched issue to an active same-company agent", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: ownerAgentId }),
...patch,
}));
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId) });
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: peerAgentId });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ assigneeAgentId: peerAgentId }),
);
});
it("still denies a watchdog run mutating an issue outside the watched subtree", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
// The watched issue is a different issue, and the target's ancestry chain
// (parentId === null) never reaches it, so it is outside the subtree.
const outsideWatched = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeef";
const app = await createApp(
watchdogActor(),
createWatchdogDb({ watchedIssueId: outsideWatched, ancestryParentId: null }),
);
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree.");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("still enforces normal assignment guards for watchdog reassignment", async () => {
// Base boundary denied AND tasks:assign denied: the watchdog grant lets the
// mutation past the ownership boundary, but the assignment guard must still bite.
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "company_scope:read" || input.action === "issue:read",
action: input.action,
reason:
input.action === "company_scope:read" || input.action === "issue:read"
? "allow_explicit_grant"
: "deny_policy_restricted",
explanation:
input.action === "tasks:assign"
? "Target agent requires approval before task assignment."
: "Watchdog test boundary default.",
}));
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId) });
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: peerAgentId });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("requires approval");
expect(mockAccessService.decide).toHaveBeenCalledWith(
expect.objectContaining({ action: "tasks:assign" }),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("denies an invalid watchdog run context even when the base boundary would allow it", async () => {
// Run context claims a watched issue, but no active persisted watchdog backs it.
const app = await createApp(
watchdogActor(),
createWatchdogDb({ watchdogRows: [] }),
);
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: peerAgentId }));
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Task-watchdog run context is not backed by an active persisted watchdog.");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
});