210 lines
7.4 KiB
TypeScript
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 });
|
|
});
|
|
});
|