Files
PaperClipAI/server/src/services/issue-rewake-throttle.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

183 lines
7.2 KiB
TypeScript

/**
* PAP-13775: throttle no-information issue re-wakes.
*
* After a process death (or any stall), external drivers — assignment pollers,
* stranded-issue reconcilers, on-demand invokes — can re-wake the same agent
* for the same issue every few seconds for as long as the issue stays
* `in_progress`. When each of those runs ends without changing any
* issue-visible state, every wake pays a full adapter session for zero new
* information (the Phase 4 interruption-recovery smoke paid 25 sessions and
* 2.4x cost for one recovery this way).
*
* This module decides when such a wake should be skipped: once an issue has
* accumulated a streak of consecutive succeeded-but-no-issue-progress runs by
* the same agent, further event-free wakes are held back for an escalating
* cooldown anchored to the last run's finish time. Fresh issue activity, an
* explicit resume, forceFreshSession, and event-carrying wake reasons bypass
* the throttle. Human comment wakes also bypass it. Agent-authored comment
* wakes deliberately stay in the normal throttle class so a cross-issue write
* cannot smuggle human wake privileges.
*
* Server-side recovery retries (process-loss retries, missing-comment
* follow-ups) insert their runs directly and never pass through this gate, so
* crash recovery stays immediate; only repeated no-op re-invocations slow
* down.
*/
/** Consecutive no-progress runs required before the cooldown engages. */
export const ISSUE_REWAKE_NO_PROGRESS_THRESHOLD = 2;
/** Cooldown after the threshold streak; doubles per additional no-progress run. */
export const ISSUE_REWAKE_BASE_COOLDOWN_MS = 120_000;
/** Upper bound for the escalating cooldown. */
export const ISSUE_REWAKE_MAX_COOLDOWN_MS = 30 * 60_000;
/** Only runs newer than this feed the streak; older history is ignored. */
export const ISSUE_REWAKE_LOOKBACK_MS = 6 * 60 * 60_000;
/** How many recent terminal runs to sample when computing the streak. */
export const ISSUE_REWAKE_RUN_SAMPLE_LIMIT = 8;
/**
* Wake reasons that assert issue state rather than deliver a new event.
* These (plus reason-less on-demand invokes) are the only wakes the throttle
* applies to; every event-shaped reason (comments, mentions, blockers
* resolved, interactions, approvals, monitors, reviews, …) passes through.
*/
export const THROTTLED_ISSUE_REWAKE_REASONS: ReadonlySet<string> = new Set([
"issue_assigned",
"issue_continuation_needed",
"issue_assignment_recovery",
"issue_graph_liveness_backstop",
]);
/**
* Activity actions that count as issue-visible progress when attributed to a
* run. Deliberately narrower than run-liveness "concrete action evidence":
* tool calls inside the workspace do not move the issue, so they do not reset
* the streak — a run must leave a comment, mutation, document, work product,
* interaction, or scheduled continuation behind.
*/
export const ISSUE_PROGRESS_ACTIVITY_ACTIONS: string[] = [
"issue.updated",
"issue.comment_added",
"issue.created",
"issue.child_created",
"issue.assigned",
"issue.released",
"issue.blockers_updated",
"issue.document_upserted",
"issue.document_updated",
"issue.document_deleted",
"issue.document_restored",
"issue.document_annotation_comment_added",
"issue.document_annotation_thread_created",
"issue.document_annotation_thread_resolved",
"issue.work_product_created",
"issue.work_product_updated",
"issue.work_product_deleted",
"issue.attachment_added",
"issue.attachment_removed",
"issue.thread_interaction_created",
"issue.monitor_scheduled",
"issue.approval_linked",
];
/**
* Activity on the issue that counts as new external input since the last run
* finished — anything a waiting agent should be woken for, including board
* responses to interactions.
*/
export const ISSUE_NEW_INPUT_ACTIVITY_ACTIONS: string[] = [
...ISSUE_PROGRESS_ACTIVITY_ACTIONS,
"issue.thread_interaction_accepted",
"issue.thread_interaction_answered",
"issue.thread_interaction_item_verdicts_submitted",
"issue.blockers_resolved_wake_emitted",
];
export interface IssueRewakeCandidateInput {
reason: string | null;
wakeCommentId: string | null;
requestedByActorType?: "user" | "agent" | "system" | null;
forceFreshSession: boolean;
hasExplicitResume: boolean;
}
/**
* Whether a wake is even a candidate for throttling. Wakes that carry new
* information or an explicit operator escalation always pass.
*/
export function isThrottleCandidateIssueRewake(input: IssueRewakeCandidateInput): boolean {
if (input.forceFreshSession) return false;
// Explicit resume is an operator privilege, not an actor-class escape hatch.
// Agent-authored resume comments remain subject to the normal rewake throttle.
if (input.hasExplicitResume && input.requestedByActorType !== "agent") return false;
if (input.wakeCommentId) return input.requestedByActorType === "agent";
if (input.reason === null) return true;
return THROTTLED_ISSUE_REWAKE_REASONS.has(input.reason);
}
export interface RecentIssueRunSample {
id: string;
status: string;
finishedAt: Date | null;
}
export interface IssueRewakeThrottleInput {
now: Date;
/** Terminal runs for the same (agent, issue), newest finish first. */
recentTerminalRuns: RecentIssueRunSample[];
/** Runs among the sample that produced issue-visible progress. */
runIdsWithIssueProgress: ReadonlySet<string>;
/** New issue input landed after the newest run finished. */
hasNewIssueInputSinceLastRun: boolean;
}
export type IssueRewakeThrottleDecision =
| { blocked: false; noProgressStreak: number }
| {
blocked: true;
noProgressStreak: number;
cooldownMs: number;
lastRunFinishedAt: Date;
nextAllowedAt: Date;
};
export function computeIssueRewakeCooldownMs(noProgressStreak: number): number {
const doublings = Math.max(0, noProgressStreak - ISSUE_REWAKE_NO_PROGRESS_THRESHOLD);
// Guard the exponent so an absurd streak can't overflow into Infinity.
const factor = 2 ** Math.min(doublings, 16);
return Math.min(ISSUE_REWAKE_BASE_COOLDOWN_MS * factor, ISSUE_REWAKE_MAX_COOLDOWN_MS);
}
export function evaluateIssueRewakeThrottle(input: IssueRewakeThrottleInput): IssueRewakeThrottleDecision {
const runs = input.recentTerminalRuns;
if (runs.length === 0) return { blocked: false, noProgressStreak: 0 };
if (input.hasNewIssueInputSinceLastRun) return { blocked: false, noProgressStreak: 0 };
let noProgressStreak = 0;
for (const run of runs) {
// A failed/cancelled/interrupted run breaks the streak: its follow-up is
// recovery, not a redundant re-poll, and must not be delayed.
if (run.status !== "succeeded" || !run.finishedAt) break;
if (input.runIdsWithIssueProgress.has(run.id)) break;
noProgressStreak += 1;
}
if (noProgressStreak < ISSUE_REWAKE_NO_PROGRESS_THRESHOLD) {
return { blocked: false, noProgressStreak };
}
const lastRunFinishedAt = runs[0]?.finishedAt;
if (!lastRunFinishedAt) return { blocked: false, noProgressStreak };
const cooldownMs = computeIssueRewakeCooldownMs(noProgressStreak);
const nextAllowedAt = new Date(lastRunFinishedAt.getTime() + cooldownMs);
if (input.now.getTime() < nextAllowedAt.getTime()) {
return { blocked: true, noProgressStreak, cooldownMs, lastRunFinishedAt, nextAllowedAt };
}
return { blocked: false, noProgressStreak };
}