mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI agents and their work > - Heartbeat admission decides when an agent should start another adapter session for an issue > - After process-loss recovery, assignment pollers and reconcilers can repeatedly request another wake while the issue remains `in_progress` > - When the preceding runs succeeded without issue-visible progress, those event-free wakes provide no new information but still pay the full cost of an adapter session > - Existing liveness evidence is too broad for this case because workspace tool calls can make a run look active without moving the issue > - This pull request adds an issue-scoped admission throttle for consecutive no-progress re-wakes while preserving every wake that carries new information or recovery intent > - The benefit is bounded recovery cost without delaying comments, operator actions, failures, or other meaningful events ## Linked Issues or Issue Description No public GitHub issue exists for this bug. **What happened?** After a process died, external wake drivers could re-wake the same agent for the same `in_progress` issue every few seconds. Each succeeded run that produced no issue-visible progress could be followed by another full adapter session despite no new issue input. In the observed recovery smoke, one recovery consumed 25 sessions and 2.4× the direct-run cost. **Expected behavior** Repeated event-free re-wakes should back off after consecutive successful runs produce no issue-visible progress. Any new information, explicit operator intent, or failed-run recovery should continue immediately. **Steps to reproduce** 1. Start an issue heartbeat and simulate process loss while the issue remains `in_progress`. 2. Allow assignment/reconciliation drivers to request repeated event-free wakes for the same agent and issue. 3. Complete each follow-up run successfully without adding a comment, issue mutation, document, work product, interaction, or continuation. 4. Observe repeated adapter sessions starting every few seconds without new issue input. **Environment** - Version: reproduced on `master` before this change - Deployment: local development, built from source - Adapter scope: core bug; not adapter-specific - Database: reproduced and tested with embedded Postgres ## What Changed - Add a pure issue re-wake throttle that detects consecutive succeeded runs without issue-visible progress and applies a 120-second exponential cooldown capped at 30 minutes. - Gate event-free `enqueueWakeup` requests and return the explicit skip reason `issue_rewake_throttled` while the cooldown is active. - Always bypass throttling for comment wakes, new issue activity, explicit resumes, `forceFreshSession`, event-shaped reasons, and post-failure recovery. - Add focused pure unit coverage and database-backed heartbeat admission coverage for throttle and bypass behavior. ## Verification - `cd server && pnpm vitest run src/__tests__/issue-rewake-throttle.test.ts` — 12 passed. - `cd server && pnpm vitest run src/__tests__/heartbeat-issue-rewake-throttle.test.ts` — 6 passed with embedded Postgres. - `cd server && pnpm run typecheck` — passed. - Neighbor suites previously verified: `heartbeat-dependency-scheduling`, `heartbeat-process-recovery`, `run-continuations`, `heartbeat-issue-liveness-escalation`, `recovery-stale-issue-lock-sweep`, and `heartbeat-comment-wake-batching` — 131 tests passed. ## Risks - A progress classifier that is too narrow could defer a legitimate event-free poll; the cooldown is bounded and new issue activity bypasses it immediately. - A progress classifier that is too broad could allow the original heartbeat storm; tests intentionally distinguish issue-visible mutations from workspace-only activity. - Low compatibility risk: no schema, API contract, or migration changes. > 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 Codex coding agent. The runtime does not expose the exact underlying model ID or context-window size; reasoning, terminal tool use, code inspection, GitHub CLI access, 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 public PR branch name describes the change 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>
178 lines
6.8 KiB
TypeScript
178 lines
6.8 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. Any genuinely new input —
|
|
* a comment wake, fresh issue activity, an explicit resume, forceFreshSession,
|
|
* or an event-carrying wake reason — bypasses the throttle entirely.
|
|
*
|
|
* 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;
|
|
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;
|
|
if (input.wakeCommentId) return false;
|
|
if (input.hasExplicitResume) return false;
|
|
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 };
|
|
}
|