Files
PaperClipAI/server/src/__tests__/issue-rewake-throttle.test.ts
T
DottaandPaperclip f91a6e27c0 feat(issues): contain cross-issue agent side effects (#10837)
## Thinking Path

> - Paperclip is the control plane that coordinates autonomous agent
work.
> - Agents need to collaborate on issues beyond their current
assignment.
> - Cross-issue comments and updates are useful, but an unbounded run
can create cascading side effects.
> - The control plane must preserve company-wide collaboration while
containing each run's influence.
> - Comment attribution must also show the responsible user and the
acting agent in audits.
> - This pull request adds run-bound cross-issue containment,
attribution, and agent-class wake rules.
> - The benefit is safer collaboration without restoring issue-assignee
ownership restrictions.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Agent-authenticated issue comments, updates, reopen behavior, and
assignee wake routing.

**Subsystem affected**

Cross-cutting: server routes and services, shared contracts, database
schema and migration, and implementation documentation.

**Current behavior**

An authenticated agent can collaborate across company issues, but one
heartbeat run has no per-run side-effect boundary. Comment records also
do not persist the responsible user separately from the acting agent.

**Proposed behavior**

Require a valid heartbeat run for agent cross-issue comments and
updates. Audit each attempt and cap a run at 20 cross-issue effects.
Keep the cap in log-only mode until it automatically changes to
enforcement at 2026-08-11 00:00 UTC. Preserve same-issue writes. Use
agent-class wakes for agent comments. Keep same-run completion comments
from reopening completed work. Record the responsible user on
agent-authored comments and activity.

**Reason and benefit**

Agents can collaborate on other issues without an assignment gate, while
each run has an atomic and inspectable side-effect limit. Operators can
identify both the acting agent and the responsible user.

**Breaking changes**

After 2026-08-11 00:00 UTC, the twenty-first cross-issue comment or
update from one heartbeat run returns a containment error. Agent
cross-issue writes without valid run context are rejected. The migration
is additive and backfills existing agent-authored comment attribution
where the source data is available.

## What Changed

- Added an atomic per-run counter for cross-issue agent comments and
updates.
- Added audit events for allowed and rejected cross-issue effects.
- Added the automatic log-only to enforcement flip at 2026-08-11 00:00
UTC.
- Added responsible-user attribution to agent-authored comments,
activity records, shared types, and validators.
- Added an additive migration and migration coverage for existing
comments.
- Updated reopen, resume, and wake behavior so agent comments create
agent-class wakes and same-run completion comments remain inert.
- Updated the implementation specification and regression coverage.

## Verification

- `pnpm exec vitest run
server/src/__tests__/cross-issue-influence-limit.test.ts
server/src/__tests__/issue-comment-attribution-audit-routes.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
packages/db/src/issue-comment-on-behalf-migration.test.ts` — 97 tests
passed.
- `pnpm -r typecheck` — passed, including migration safety checks.
- `pnpm test:run` — server batch: 3,364 passed and 2 skipped; UI batch:
3,504 passed. One unrelated CLI doctor test warned because this agent
runtime injects static AWS credentials.
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts` — 8 tests passed and confirmed
the CLI failure was ambient-environment sensitive.
- `pnpm build` — passed.

## Risks

- The fixed enforcement timestamp changes production behavior
automatically on 2026-08-11 00:00 UTC. Audit logs before that time
provide rollout visibility.
- The per-run counter serializes on the heartbeat-run row. This prevents
concurrent attempts from racing past the cap but adds a small lock scope
for cross-issue writes.
- Existing comments can only be backfilled when their acting run or
agent attribution is recoverable.

> 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

OpenAI GPT-5 in the Codex agent runtime. The runtime did not expose a
context-window size. Reasoning, shell tools, code editing, and test
execution were enabled.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-04 13:17:49 -05:00

210 lines
7.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
ISSUE_REWAKE_BASE_COOLDOWN_MS,
ISSUE_REWAKE_MAX_COOLDOWN_MS,
ISSUE_REWAKE_NO_PROGRESS_THRESHOLD,
computeIssueRewakeCooldownMs,
evaluateIssueRewakeThrottle,
isThrottleCandidateIssueRewake,
} from "../services/issue-rewake-throttle.ts";
const NOW = new Date("2026-07-12T18:14:00.000Z");
function runSample(input: {
id: string;
status?: string;
finishedSecondsAgo: number;
}) {
return {
id: input.id,
status: input.status ?? "succeeded",
finishedAt: new Date(NOW.getTime() - input.finishedSecondsAgo * 1000),
};
}
describe("isThrottleCandidateIssueRewake", () => {
const base = {
reason: "issue_assigned",
wakeCommentId: null,
requestedByActorType: "system" as const,
forceFreshSession: false,
hasExplicitResume: false,
};
it("throttles state-poll reasons and reason-less invokes", () => {
expect(isThrottleCandidateIssueRewake(base)).toBe(true);
expect(isThrottleCandidateIssueRewake({ ...base, reason: null })).toBe(true);
expect(isThrottleCandidateIssueRewake({ ...base, reason: "issue_continuation_needed" })).toBe(true);
expect(isThrottleCandidateIssueRewake({ ...base, reason: "issue_assignment_recovery" })).toBe(true);
expect(isThrottleCandidateIssueRewake({ ...base, reason: "issue_graph_liveness_backstop" })).toBe(true);
});
it("keeps agent comments throttle-eligible without granting human comment privileges", () => {
expect(isThrottleCandidateIssueRewake({
...base,
reason: "issue_commented",
wakeCommentId: "comment-1",
requestedByActorType: "agent",
})).toBe(true);
expect(isThrottleCandidateIssueRewake({
...base,
reason: "issue_commented",
wakeCommentId: "comment-1",
requestedByActorType: "user",
})).toBe(false);
});
it("never throttles trusted explicit escalation wakes", () => {
expect(isThrottleCandidateIssueRewake({ ...base, forceFreshSession: true })).toBe(false);
expect(isThrottleCandidateIssueRewake({ ...base, hasExplicitResume: true })).toBe(false);
});
it("keeps agent-authored explicit resume comments throttle-eligible", () => {
expect(isThrottleCandidateIssueRewake({
...base,
reason: "issue_reopened_via_comment",
wakeCommentId: "comment-1",
requestedByActorType: "agent",
hasExplicitResume: true,
})).toBe(true);
});
it("passes event-shaped wake reasons through", () => {
for (const reason of [
"issue_commented",
"issue_comment_mentioned",
"issue_blockers_resolved",
"issue_children_completed",
"issue_monitor_due",
"process_lost_retry",
"run_liveness_continuation",
]) {
expect(isThrottleCandidateIssueRewake({ ...base, reason })).toBe(false);
}
});
});
describe("computeIssueRewakeCooldownMs", () => {
it("starts at the base cooldown and doubles per extra no-progress run, capped", () => {
expect(computeIssueRewakeCooldownMs(ISSUE_REWAKE_NO_PROGRESS_THRESHOLD)).toBe(ISSUE_REWAKE_BASE_COOLDOWN_MS);
expect(computeIssueRewakeCooldownMs(ISSUE_REWAKE_NO_PROGRESS_THRESHOLD + 1)).toBe(ISSUE_REWAKE_BASE_COOLDOWN_MS * 2);
expect(computeIssueRewakeCooldownMs(ISSUE_REWAKE_NO_PROGRESS_THRESHOLD + 3)).toBe(ISSUE_REWAKE_BASE_COOLDOWN_MS * 8);
expect(computeIssueRewakeCooldownMs(100)).toBe(ISSUE_REWAKE_MAX_COOLDOWN_MS);
});
});
describe("evaluateIssueRewakeThrottle", () => {
it("allows when there is no run history", () => {
expect(
evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: false,
}),
).toEqual({ blocked: false, noProgressStreak: 0 });
});
it("allows below the no-progress threshold", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [runSample({ id: "r1", finishedSecondsAgo: 10 })],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: false,
});
expect(decision).toEqual({ blocked: false, noProgressStreak: 1 });
});
it("blocks inside the cooldown once the streak reaches the threshold", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [
runSample({ id: "r2", finishedSecondsAgo: 10 }),
runSample({ id: "r1", finishedSecondsAgo: 40 }),
],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: false,
});
expect(decision.blocked).toBe(true);
if (decision.blocked) {
expect(decision.noProgressStreak).toBe(2);
expect(decision.cooldownMs).toBe(ISSUE_REWAKE_BASE_COOLDOWN_MS);
expect(decision.nextAllowedAt.getTime()).toBe(
NOW.getTime() - 10_000 + ISSUE_REWAKE_BASE_COOLDOWN_MS,
);
}
});
it("allows again after the cooldown elapses", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [
runSample({ id: "r2", finishedSecondsAgo: ISSUE_REWAKE_BASE_COOLDOWN_MS / 1000 + 1 }),
runSample({ id: "r1", finishedSecondsAgo: ISSUE_REWAKE_BASE_COOLDOWN_MS / 1000 + 30 }),
],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: false,
});
expect(decision).toEqual({ blocked: false, noProgressStreak: 2 });
});
it("escalates the cooldown as the streak grows", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [
runSample({ id: "r4", finishedSecondsAgo: 10 }),
runSample({ id: "r3", finishedSecondsAgo: 30 }),
runSample({ id: "r2", finishedSecondsAgo: 60 }),
runSample({ id: "r1", finishedSecondsAgo: 90 }),
],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: false,
});
expect(decision.blocked).toBe(true);
if (decision.blocked) {
expect(decision.noProgressStreak).toBe(4);
expect(decision.cooldownMs).toBe(ISSUE_REWAKE_BASE_COOLDOWN_MS * 4);
}
});
it("resets at the most recent run with issue-visible progress", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [
runSample({ id: "r3", finishedSecondsAgo: 10 }),
runSample({ id: "r2", finishedSecondsAgo: 40 }),
runSample({ id: "r1", finishedSecondsAgo: 70 }),
],
runIdsWithIssueProgress: new Set(["r2"]),
hasNewIssueInputSinceLastRun: false,
});
expect(decision).toEqual({ blocked: false, noProgressStreak: 1 });
});
it("does not delay recovery after a failed run", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [
runSample({ id: "r2", status: "failed", finishedSecondsAgo: 10 }),
runSample({ id: "r1", finishedSecondsAgo: 40 }),
],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: false,
});
expect(decision).toEqual({ blocked: false, noProgressStreak: 0 });
});
it("allows when new issue input landed after the last run", () => {
const decision = evaluateIssueRewakeThrottle({
now: NOW,
recentTerminalRuns: [
runSample({ id: "r2", finishedSecondsAgo: 10 }),
runSample({ id: "r1", finishedSecondsAgo: 40 }),
],
runIdsWithIssueProgress: new Set(),
hasNewIssueInputSinceLastRun: true,
});
expect(decision).toEqual({ blocked: false, noProgressStreak: 0 });
});
});