fix(recovery): wait for provider quota resets (#9635)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies and keep assigned work moving safely. > - The recovery subsystem decides whether a failed agent run should retry, wait, block for configuration, or escalate to another owner. > - Provider usage-limit failures currently arrive as generic `adapter_failed` results, so stranded-work reconciliation can create takeover recovery even when the provider states that capacity will reset later. > - Credential and model lookup failures are also configuration problems, not evidence that another agent should take over the task. > - This pull request classifies those failure families at recovery time and persists the classification on the run. > - Quota failures now schedule a monitor for the original assignee at the parsed reset time, or after a bounded default backoff when no reset time is available. > - The benefit is that transient provider capacity waits no longer wake recovery owners, while configuration failures stop with an actionable classification. ## Linked Issues or Issue Description No public GitHub issue exists for this exact change. **What happened?** When an assigned issue's latest run failed with a provider usage-limit message such as "try again at 12:00 AM (UTC)," recovery treated the run as generic `adapter_failed` work and could create a takeover action. Missing credentials and `model_not_found` failures followed the same generic path. **Expected behavior:** Provider quota failures should keep the original assignee and schedule a monitor for the reset time, without creating recovery work or immediately waking another owner. Missing credentials and model lookup failures should be classified as `configuration_incomplete` and blocked with the configuration fix recorded. **Steps to reproduce:** 1. Assign and start an issue for an agent. 2. Record a failed heartbeat run with `errorCode: adapter_failed` and a provider quota/reset message. 3. Run stranded assigned-issue reconciliation. 4. Observe that the old behavior routes the issue through generic recovery instead of waiting for provider capacity. Reproduced on `master` at `9af96461d`. This is a core recovery bug, not adapter-specific, and applies to built-from-source deployments with either embedded PGlite or Postgres. Related work checked: #9288 adds adapter-side Claude provider-limit classification; #5392 suppresses some recovery creation for quota-class errors; #9634 is a broader recovery-routing change with overlapping provider-quota behavior. This PR is the narrow recovery-service fix with focused parsed-reset, fallback-backoff, zero-takeover, and configuration-failure coverage. ## What Changed - Added conservative recovery-time classification for provider quota, missing-credential, and model-not-found adapter failures. - Parsed provider reset timestamps with a default one-hour backoff when no usable reset time is present. - Persisted `provider_quota` or `configuration_incomplete` metadata on the failed heartbeat run. - Scheduled quota monitors for the active issue owner, including the current review participant, without creating recovery actions or enqueueing takeover wakes. - Routed configuration failures to blocked recovery with actionable evidence instead of a takeover. - Added unit and embedded-database regression coverage for parsed/fallback quota timing, zero CTO/recovery wake behavior, and configuration classification. ## Verification - `pnpm exec vitest run server/src/services/recovery/provider-failure-classification.test.ts server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/issue-monitor-scheduler.test.ts server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts` — 4 files passed, 66 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. ## Risks - Recovery behavior changes for text-matched adapter failures; matching is intentionally conservative, and unmatched failures retain the existing generic recovery path. - Provider reset strings do not always include a date or timezone; parsing chooses the next future matching time and falls back to a one-hour wait when the timestamp is unusable. - This overlaps the provider-quota portion of broader recovery-routing PR #9634, so only one implementation should land if both remain open. - No schema, migration, API contract, or UI changes are included. No documentation update is needed because this corrects internal recovery behavior without changing operator commands or configuration. > 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 with model `gpt-5.4`, medium reasoning, tool use, and code execution. The runtime does not expose its configured 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 - [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 - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
85404b46c5
commit
c65ab09d9f
|
|
@ -444,6 +444,8 @@ export type IssueMonitorScheduledBy = (typeof ISSUE_MONITOR_SCHEDULED_BY)[number
|
|||
export const ISSUE_EXECUTION_MONITOR_KINDS = ["external_service"] as const;
|
||||
export type IssueExecutionMonitorKind = (typeof ISSUE_EXECUTION_MONITOR_KINDS)[number];
|
||||
|
||||
export const PROVIDER_QUOTA_MONITOR_SERVICE_NAME = "AI provider quota";
|
||||
|
||||
export const ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES = [
|
||||
"wake_owner",
|
||||
"create_recovery_issue",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ export {
|
|||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_MONITOR_SCHEDULED_BY,
|
||||
ISSUE_EXECUTION_MONITOR_KINDS,
|
||||
PROVIDER_QUOTA_MONITOR_SERVICE_NAME,
|
||||
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
|
||||
import {
|
||||
activityLog,
|
||||
agentRuntimeState,
|
||||
|
|
@ -270,6 +271,73 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
|||
expect(activity).toContain("issue.monitor_triggered");
|
||||
});
|
||||
|
||||
it("wakes a cross-agent review participant for provider quota monitors", async () => {
|
||||
const { companyId, issueId, agentId: assigneeAgentId } = await seedFixture({
|
||||
issueStatus: "in_review",
|
||||
monitor: { serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME },
|
||||
});
|
||||
const participantAgentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: participantAgentId,
|
||||
companyId,
|
||||
name: "Quota-limited reviewer",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: ["-e", ""],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
wakeOnDemand: true,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
seededAgentIds.add(participantAgentId);
|
||||
const monitorState = await db
|
||||
.select({ executionState: issues.executionState })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => parseIssueExecutionState(rows[0]?.executionState ?? null)?.monitor ?? null);
|
||||
await db.update(issues).set({
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: randomUUID(),
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: participantAgentId, userId: null },
|
||||
returnAssignee: { type: "agent", agentId: assigneeAgentId, userId: null },
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: monitorState,
|
||||
},
|
||||
}).where(eq(issues.id, issueId));
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.tickTimers(new Date("2026-04-11T12:31:00.000Z"));
|
||||
|
||||
expect(result.enqueued).toBe(1);
|
||||
const wakeups = await db.select().from(agentWakeupRequests);
|
||||
expect(wakeups).toHaveLength(1);
|
||||
expect(wakeups[0]).toMatchObject({
|
||||
agentId: participantAgentId,
|
||||
reason: "execution_review_participant_recovery",
|
||||
});
|
||||
await waitForHeartbeatIdle();
|
||||
const participantRuns = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, participantAgentId));
|
||||
expect(participantRuns).toHaveLength(1);
|
||||
expect(participantRuns[0]?.errorCode).not.toBe("issue_assignee_changed");
|
||||
});
|
||||
|
||||
it("lets the board trigger a scheduled issue monitor immediately", async () => {
|
||||
const { issueId, agentId, nextCheckAt } = await seedFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
|
|
|||
|
|
@ -380,72 +380,538 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("creates a quota wait-recovery monitor without enqueueing a takeover wake", async () => {
|
||||
const { companyId, coderId, sourceIssue } = await seedCompany();
|
||||
it("schedules a provider-quota monitor for the original assignee without creating recovery work", async () => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
const runId = randomUUID();
|
||||
await seedHeartbeatRun({ companyId, agentId: coderId, runId, issueId: sourceIssue.id, status: "failed" });
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "You've hit your usage limit for GPT-5. Try again at 12:00 AM (UTC).",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
const retryAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
await recovery.escalateStrandedAssignedIssue({
|
||||
issue: sourceIssue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun: {
|
||||
id: runId,
|
||||
agentId: coderId,
|
||||
status: "failed",
|
||||
error: "provider usage limit exceeded",
|
||||
errorCode: "adapter_failed",
|
||||
contextSnapshot: { retryReason: "issue_continuation_needed" },
|
||||
livenessState: "needs_followup",
|
||||
resultJson: { errorFamily: "provider_quota", providerQuotaRetryNotBefore: retryAt },
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result.providerQuotaMonitored).toBe(1);
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderId,
|
||||
monitorScheduledBy: "assignee",
|
||||
monitorNotes: "Provider usage quota reached; retry the original assignee at the provider reset time.",
|
||||
});
|
||||
expect(updatedIssue?.monitorNextCheckAt).toBeInstanceOf(Date);
|
||||
expect(updatedIssue?.executionPolicy).toMatchObject({
|
||||
monitor: {
|
||||
serviceName: "AI provider quota",
|
||||
externalRef: runId,
|
||||
maxAttempts: null,
|
||||
recoveryPolicy: "wake_owner",
|
||||
},
|
||||
});
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun).toMatchObject({ errorCode: "provider_quota" });
|
||||
expect(updatedRun?.resultJson).toMatchObject({ errorFamily: "provider_quota" });
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
|
||||
const secondResult = await recovery.reconcileStrandedAssignedIssues();
|
||||
expect(secondResult).toMatchObject({ providerQuotaMonitored: 0, skipped: 1 });
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("schedules another provider-quota monitor after a prior quota monitor fired", async () => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
await db.update(issues).set({ monitorAttemptCount: 1 }).where(eq(issues.id, sourceIssueId));
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T21:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T21:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result.providerQuotaMonitored).toBe(1);
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue?.executionPolicy).toMatchObject({
|
||||
monitor: {
|
||||
maxAttempts: null,
|
||||
externalRef: runId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skips provider-quota monitor scheduling for todo issues without aborting reconciliation", async () => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, sourceIssueId));
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ providerQuotaMonitored: 0, skipped: 1 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "todo",
|
||||
assigneeAgentId: coderId,
|
||||
monitorNextCheckAt: null,
|
||||
});
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun?.errorCode).toBe("adapter_failed");
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create takeover recovery when a quota monitor cannot be scheduled", async () => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, sourceIssueId));
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ providerQuotaMonitored: 0, skipped: 1 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
monitorNextCheckAt: null,
|
||||
});
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun?.errorCode).toBe("adapter_failed");
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules a quota monitor for a cross-agent active review participant", async () => {
|
||||
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
|
||||
const stageId = randomUUID();
|
||||
await db.update(issues).set({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [{
|
||||
id: stageId,
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: randomUUID(), type: "agent", agentId: managerId, userId: null }],
|
||||
}],
|
||||
},
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: stageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: managerId, userId: null },
|
||||
returnAssignee: { type: "agent", agentId: coderId, userId: null },
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
}).where(eq(issues.id, sourceIssueId));
|
||||
const [reviewIssueBeforeRecovery] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(reviewIssueBeforeRecovery).toMatchObject({
|
||||
assigneeAgentId: coderId,
|
||||
executionState: {
|
||||
currentParticipant: { type: "agent", agentId: managerId },
|
||||
returnAssignee: { type: "agent", agentId: coderId },
|
||||
},
|
||||
});
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: managerId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ providerQuotaMonitored: 1, reviewParticipantRequeued: 0 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
monitorNextCheckAt: expect.any(Date),
|
||||
monitorNotes: "Provider usage quota reached; retry the active review participant after the default recovery backoff.",
|
||||
});
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun?.errorCode).toBe("provider_quota");
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restamp an in_review quota monitor when the assignee has a newer terminal run", async () => {
|
||||
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
|
||||
const stageId = randomUUID();
|
||||
await db.update(issues).set({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [{
|
||||
id: stageId,
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: randomUUID(), type: "agent", agentId: managerId, userId: null }],
|
||||
}],
|
||||
},
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: stageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: managerId, userId: null },
|
||||
returnAssignee: { type: "agent", agentId: coderId, userId: null },
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
}).where(eq(issues.id, sourceIssueId));
|
||||
const participantRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: participantRunId,
|
||||
companyId,
|
||||
agentId: managerId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const firstResult = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(firstResult).toMatchObject({ providerQuotaMonitored: 1 });
|
||||
const [monitoredIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
const firstNextCheckAt = monitoredIssue?.monitorNextCheckAt;
|
||||
expect(firstNextCheckAt).toBeInstanceOf(Date);
|
||||
expect(monitoredIssue?.executionPolicy).toMatchObject({
|
||||
monitor: {
|
||||
serviceName: "AI provider quota",
|
||||
externalRef: participantRunId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
const [action] = await db
|
||||
.select()
|
||||
.from(issueRecoveryActions)
|
||||
.where(eq(issueRecoveryActions.sourceIssueId, sourceIssue.id));
|
||||
expect(action).toMatchObject({
|
||||
cause: "provider_quota",
|
||||
ownerType: "system",
|
||||
ownerAgentId: null,
|
||||
returnOwnerAgentId: coderId,
|
||||
wakePolicy: expect.objectContaining({ type: "monitor_only" }),
|
||||
monitorPolicy: expect.objectContaining({ type: "wait_recovery", retryAgentId: coderId }),
|
||||
});
|
||||
const scheduled = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.status, "scheduled_retry"));
|
||||
expect(scheduled).toHaveLength(1);
|
||||
expect(scheduled[0]).toMatchObject({
|
||||
agentId: coderId,
|
||||
scheduledRetryReason: "provider_quota_recovery",
|
||||
});
|
||||
expect(scheduled[0]?.contextSnapshot).toMatchObject({
|
||||
issueId: sourceIssue.id,
|
||||
wakeReason: "provider_quota_recovery",
|
||||
});
|
||||
expect(scheduled[0]?.contextSnapshot).not.toHaveProperty("recoveryActionId");
|
||||
expect(scheduled[0]?.contextSnapshot).not.toHaveProperty("recoveryCause");
|
||||
const wakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
contextSnapshot: scheduled[0]?.contextSnapshot as Record<string, unknown>,
|
||||
agentId: coderId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "Stale assignee wake fired after the issue entered review.",
|
||||
errorCode: "issue_assignee_changed",
|
||||
startedAt: new Date("2026-07-15T20:02:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:03:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
expect(wakePayload?.reason).toBe("provider_quota_recovery");
|
||||
expect(wakePayload?.recovery).toBeNull();
|
||||
const [updatedIssue] = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, sourceIssue.id));
|
||||
|
||||
const secondResult = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(secondResult).toMatchObject({ providerQuotaMonitored: 0, skipped: 1 });
|
||||
const [unchangedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(unchangedIssue?.monitorNextCheckAt?.getTime()).toBe(firstNextCheckAt?.getTime());
|
||||
expect(unchangedIssue?.executionPolicy).toMatchObject({
|
||||
monitor: {
|
||||
serviceName: "AI provider quota",
|
||||
externalRef: participantRunId,
|
||||
},
|
||||
});
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies review recovery from the active participant run instead of a newer assignee run", async () => {
|
||||
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
|
||||
const stageId = randomUUID();
|
||||
await db.update(issues).set({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [{
|
||||
id: stageId,
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: randomUUID(), type: "agent", agentId: managerId, userId: null }],
|
||||
}],
|
||||
},
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: stageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: managerId, userId: null },
|
||||
returnAssignee: { type: "agent", agentId: coderId, userId: null },
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
}).where(eq(issues.id, sourceIssueId));
|
||||
const participantRunId = randomUUID();
|
||||
const assigneeRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values([{
|
||||
id: participantRunId,
|
||||
companyId,
|
||||
agentId: managerId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "review process exited unexpectedly",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
}, {
|
||||
id: assigneeRunId,
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "You've hit your usage limit. Try again at 11:00 PM (UTC)",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:02:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:03:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
}]);
|
||||
const enqueueWakeup = vi.fn(async () => ({ id: randomUUID() } as never));
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ providerQuotaMonitored: 0, reviewParticipantRequeued: 1 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
monitorNextCheckAt: null,
|
||||
});
|
||||
const [assigneeRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, assigneeRunId));
|
||||
expect(assigneeRun?.errorCode).toBe("adapter_failed");
|
||||
expect(enqueueWakeup).toHaveBeenCalledWith(managerId, expect.objectContaining({
|
||||
reason: "execution_review_participant_recovery",
|
||||
payload: expect.objectContaining({ issueId: sourceIssueId, retryOfRunId: participantRunId }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks a cross-agent review participant with incomplete configuration", async () => {
|
||||
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
|
||||
const stageId = randomUUID();
|
||||
await db.update(issues).set({
|
||||
status: "in_review",
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [{
|
||||
id: stageId,
|
||||
type: "review",
|
||||
approvalsNeeded: 1,
|
||||
participants: [{ id: randomUUID(), type: "agent", agentId: managerId, userId: null }],
|
||||
}],
|
||||
},
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: stageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: managerId, userId: null },
|
||||
returnAssignee: { type: "agent", agentId: coderId, userId: null },
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
}).where(eq(issues.id, sourceIssueId));
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: managerId,
|
||||
invocationSource: "automation",
|
||||
status: "failed",
|
||||
error: "model_not_found: requested review model does not exist",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => ({ id: randomUUID() } as never));
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ escalated: 1, reviewParticipantRequeued: 0 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "blocked",
|
||||
assigneeAgentId: managerId,
|
||||
});
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun?.errorCode).toBe("configuration_incomplete");
|
||||
const [action] = await db.select().from(issueRecoveryActions);
|
||||
expect(action).toMatchObject({
|
||||
sourceIssueId,
|
||||
ownerAgentId: managerId,
|
||||
previousOwnerAgentId: coderId,
|
||||
cause: "configuration_incomplete",
|
||||
recoveryIssueId: null,
|
||||
});
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the default quota backoff when the provider does not state a reset time", async () => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result.providerQuotaMonitored).toBe(1);
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderId,
|
||||
monitorNotes: "Provider usage quota reached; retry the original assignee after the default recovery backoff.",
|
||||
});
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("classifies model lookup failures as configuration incomplete without waking a recovery owner", async () => {
|
||||
const { companyId, coderId, sourceIssueId } = await seedCompany();
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "model_not_found: requested model does not exist",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ escalated: 1, skipped: 0 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue?.status).toBe("blocked");
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun?.errorCode).toBe("configuration_incomplete");
|
||||
const [action] = await db.select().from(issueRecoveryActions);
|
||||
expect(action).toMatchObject({
|
||||
sourceIssueId,
|
||||
cause: "configuration_incomplete",
|
||||
recoveryIssueId: null,
|
||||
});
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not classify stale configuration failures from a non-assignee run", async () => {
|
||||
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: managerId,
|
||||
invocationSource: "manual",
|
||||
status: "failed",
|
||||
error: "model_not_found: previous assignee model does not exist",
|
||||
errorCode: "adapter_failed",
|
||||
startedAt: new Date("2026-07-15T20:00:00.000Z"),
|
||||
finishedAt: new Date("2026-07-15T20:01:00.000Z"),
|
||||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
const enqueueWakeup = vi.fn(async () => null);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
|
||||
const result = await recovery.reconcileStrandedAssignedIssues();
|
||||
|
||||
expect(result).toMatchObject({ escalated: 0, skipped: 1 });
|
||||
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(updatedIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderId,
|
||||
});
|
||||
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(updatedRun?.errorCode).toBe("adapter_failed");
|
||||
expect(await db.select().from(issueRecoveryActions)).toHaveLength(0);
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses the same source-scoped action when latest run IDs change while the cause stays the same", async () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
MODEL_PROFILE_KEYS,
|
||||
PROVIDER_QUOTA_MONITOR_SERVICE_NAME,
|
||||
envBindingSchema,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
type BillingType,
|
||||
|
|
@ -6617,6 +6618,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
maxAttempts: monitor?.maxAttempts ?? null,
|
||||
recoveryPolicy: monitor?.recoveryPolicy ?? null,
|
||||
};
|
||||
const executionState = claimed.status === "in_review"
|
||||
? parseIssueExecutionState(claimed.executionState)
|
||||
: null;
|
||||
const currentParticipant = executionState?.status === "pending"
|
||||
? executionState.currentParticipant
|
||||
: null;
|
||||
const reviewParticipantAgentId = currentParticipant?.type === "agent"
|
||||
? currentParticipant.agentId
|
||||
: null;
|
||||
const isProviderQuotaReviewMonitor = monitor?.serviceName === PROVIDER_QUOTA_MONITOR_SERVICE_NAME &&
|
||||
Boolean(reviewParticipantAgentId);
|
||||
const targetAgentId = isProviderQuotaReviewMonitor
|
||||
? reviewParticipantAgentId
|
||||
: claimed.assigneeAgentId;
|
||||
if (!targetAgentId) {
|
||||
throw conflict("Issue monitor has no agent target");
|
||||
}
|
||||
const wakeReason = isProviderQuotaReviewMonitor
|
||||
? EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON
|
||||
: input.wakeReason;
|
||||
const reviewRecoveryContext = isProviderQuotaReviewMonitor
|
||||
? {
|
||||
retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
|
||||
currentStageId: executionState?.currentStageId ?? null,
|
||||
currentStageType: executionState?.currentStageType ?? null,
|
||||
reviewRecoveryInstruction:
|
||||
"The previous reviewer run reached provider quota. Resume this execution-review stage now that the quota wait has elapsed.",
|
||||
}
|
||||
: {};
|
||||
|
||||
if (clearReason) {
|
||||
return clearIssueMonitorAndRecover({
|
||||
|
|
@ -6637,10 +6667,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
|
||||
try {
|
||||
await enqueueWakeup(claimed.assigneeAgentId, {
|
||||
await enqueueWakeup(targetAgentId, {
|
||||
source: input.source,
|
||||
triggerDetail: input.triggerDetail,
|
||||
reason: input.wakeReason,
|
||||
reason: wakeReason,
|
||||
idempotencyKey: `issue-monitor:${claimed.id}:${scheduledAtIso}`,
|
||||
payload: {
|
||||
issueId: claimed.id,
|
||||
|
|
@ -6648,18 +6678,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
monitorAttemptCount: nextAttemptCount,
|
||||
monitorNotes: claimed.monitorNotes ?? null,
|
||||
...monitorMetadata,
|
||||
...reviewRecoveryContext,
|
||||
source: input.activitySource,
|
||||
},
|
||||
requestedByActorType: input.actorType,
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: claimed.id,
|
||||
source: "issue.monitor",
|
||||
wakeReason: input.wakeReason,
|
||||
source: isProviderQuotaReviewMonitor ? "issue.execution_review_recovery" : "issue.monitor",
|
||||
wakeReason,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
monitorAttemptCount: nextAttemptCount,
|
||||
monitorNotes: claimed.monitorNotes ?? null,
|
||||
...monitorMetadata,
|
||||
...reviewRecoveryContext,
|
||||
manualTrigger: input.activitySource === "manual",
|
||||
},
|
||||
});
|
||||
|
|
@ -10866,7 +10898,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
}
|
||||
|
||||
if (issue.assigneeAgentId !== run.agentId && !isInteractionWake) {
|
||||
const reviewExecutionState = issue.status === "in_review"
|
||||
? parseIssueExecutionState(issue.executionState)
|
||||
: null;
|
||||
const reviewParticipant = reviewExecutionState?.currentParticipant ?? null;
|
||||
const isCurrentReviewParticipant = reviewParticipant?.type === "agent" &&
|
||||
reviewParticipant.agentId === run.agentId;
|
||||
|
||||
if (issue.assigneeAgentId !== run.agentId && !isInteractionWake && !isCurrentReviewParticipant) {
|
||||
return {
|
||||
stale: true,
|
||||
errorCode: "issue_assignee_changed",
|
||||
|
|
@ -10915,8 +10954,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
|
||||
if (issue.status === "in_review") {
|
||||
const executionState = parseIssueExecutionState(issue.executionState);
|
||||
const currentParticipant = executionState?.currentParticipant ?? null;
|
||||
const currentParticipant = reviewExecutionState?.currentParticipant ?? null;
|
||||
if (currentParticipant) {
|
||||
const participantMatches =
|
||||
currentParticipant.type === "agent" && currentParticipant.agentId === run.agentId;
|
||||
|
|
@ -10928,7 +10966,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
"Cancelled because the in-review participant changed before the queued run could start; the current participant will be woken instead",
|
||||
details: {
|
||||
issueId,
|
||||
currentStageType: executionState?.currentStageType ?? null,
|
||||
currentStageType: reviewExecutionState?.currentStageType ?? null,
|
||||
currentParticipant,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1053,3 +1053,7 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
|||
Object.assign(stageResult.patch, monitorPatch);
|
||||
return stageResult;
|
||||
}
|
||||
|
||||
export function applyIssueMonitorPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
return { patch: applyMonitorTransition(input, {}) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS,
|
||||
classifyAdapterFailureForRecovery,
|
||||
} from "./service.js";
|
||||
|
||||
describe("classifyAdapterFailureForRecovery", () => {
|
||||
it("classifies usage-limit messages and parses the provider reset time", () => {
|
||||
const now = new Date("2026-07-15T20:00:00.000Z");
|
||||
const classification = classifyAdapterFailureForRecovery({
|
||||
errorCode: "adapter_failed",
|
||||
error: "You've hit your usage limit for GPT-5. Try again at 4:30 PM (America/Chicago).",
|
||||
resultJson: null,
|
||||
}, now);
|
||||
|
||||
expect(classification).toEqual({
|
||||
kind: "provider_quota",
|
||||
retryAt: new Date("2026-07-15T21:30:00.000Z"),
|
||||
parsedResetTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the default recovery backoff when quota reset time is absent", () => {
|
||||
const now = new Date("2026-07-15T20:00:00.000Z");
|
||||
const classification = classifyAdapterFailureForRecovery({
|
||||
errorCode: "adapter_failed",
|
||||
error: "Provider quota exceeded for this model.",
|
||||
resultJson: null,
|
||||
}, now);
|
||||
|
||||
expect(classification).toEqual({
|
||||
kind: "provider_quota",
|
||||
retryAt: new Date(now.getTime() + PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS),
|
||||
parsedResetTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats timezone-less provider reset clocks as UTC", () => {
|
||||
const now = new Date("2026-07-15T20:00:00.000Z");
|
||||
const classification = classifyAdapterFailureForRecovery({
|
||||
errorCode: "adapter_failed",
|
||||
error: "You've hit your usage limit. Try again at 4:30 PM.",
|
||||
resultJson: null,
|
||||
}, now);
|
||||
|
||||
expect(classification).toEqual({
|
||||
kind: "provider_quota",
|
||||
retryAt: new Date("2026-07-16T16:30:00.000Z"),
|
||||
parsedResetTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses provider reset clocks in 24-hour format", () => {
|
||||
const now = new Date("2026-07-15T20:00:00.000Z");
|
||||
const classification = classifyAdapterFailureForRecovery({
|
||||
errorCode: "adapter_failed",
|
||||
error: "You've hit your usage limit. Try again at 21:30 (UTC).",
|
||||
resultJson: null,
|
||||
}, now);
|
||||
|
||||
expect(classification).toEqual({
|
||||
kind: "provider_quota",
|
||||
retryAt: new Date("2026-07-15T21:30:00.000Z"),
|
||||
parsedResetTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"model_not_found: requested model does not exist",
|
||||
"No API credentials were found for this provider",
|
||||
"API key is not set",
|
||||
])("classifies configuration failures: %s", (error) => {
|
||||
expect(classifyAdapterFailureForRecovery({
|
||||
errorCode: "adapter_failed",
|
||||
error,
|
||||
resultJson: null,
|
||||
})).toEqual({ kind: "configuration_incomplete" });
|
||||
});
|
||||
|
||||
it("ignores quota-like text from non-adapter failures", () => {
|
||||
expect(classifyAdapterFailureForRecovery({
|
||||
errorCode: "timeout",
|
||||
error: "Provider quota exceeded while waiting for a downstream service.",
|
||||
resultJson: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat a generic capacity limit as provider quota", () => {
|
||||
expect(classifyAdapterFailureForRecovery({
|
||||
errorCode: "adapter_failed",
|
||||
error: "Workspace storage capacity limit reached.",
|
||||
resultJson: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
PROVIDER_QUOTA_MONITOR_SERVICE_NAME,
|
||||
type IssueGraphLivenessAutoRecoveryPreview,
|
||||
type IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -38,12 +39,16 @@ import { instanceSettingsService } from "../instance-settings.js";
|
|||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||
import { TERMINAL_HEARTBEAT_RUN_STATUSES, issueService } from "../issues.js";
|
||||
import {
|
||||
applyIssueMonitorPolicyTransition,
|
||||
normalizeIssueExecutionPolicy,
|
||||
parseIssueExecutionState,
|
||||
} from "../issue-execution-policy.js";
|
||||
import {
|
||||
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
|
||||
buildIssueBlockersResolvedWakeIdempotencyKey,
|
||||
findExistingIssueBlockersResolvedWakeForAnyKey,
|
||||
} from "../issue-dependency-wakeups.js";
|
||||
import { parseIssueExecutionState } from "../issue-execution-policy.js";
|
||||
import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js";
|
||||
import { getRunLogStore } from "../run-log-store.js";
|
||||
import {
|
||||
|
|
@ -159,8 +164,6 @@ type SuccessfulRunHandoffRecoveryEvidence = {
|
|||
maxHandoffAttempts: number;
|
||||
};
|
||||
|
||||
const PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS = 15 * 60 * 1000;
|
||||
|
||||
function readRecoveryRunErrorFamily(latestRun: LatestIssueRun) {
|
||||
const result = parseObject(latestRun?.resultJson);
|
||||
return readNonEmptyString(result.errorFamily);
|
||||
|
|
@ -295,6 +298,122 @@ const INTERACTION_CONTINUATION_REQUEUE_MAX_ATTEMPTS = 3;
|
|||
const CONTINUATION_RECOVERY_TRANSIENT_MAX_ATTEMPTS = 3;
|
||||
const CONTINUATION_RECOVERY_DEFAULT_MAX_ATTEMPTS = 1;
|
||||
const CONTINUATION_RECOVERY_TRANSIENT_BASE_BACKOFF_MS = 60_000;
|
||||
export const PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS = 60 * 60 * 1000;
|
||||
|
||||
const PROVIDER_QUOTA_ERROR_RE =
|
||||
/(?:you(?:'|’)ve hit your usage limit|usage limit(?: reached| exceeded)?|provider quota|quota (?:limit )?exceeded|model (?:is )?at capacity)/i;
|
||||
const CONFIGURATION_INCOMPLETE_ERROR_RE =
|
||||
/(?:model_not_found|model [^\n]{0,120} not found|missing (?:api )?(?:key|credentials?)|credentials? (?:are |is )?missing|no (?:api )?(?:key|credentials?) (?:was |were )?(?:found|configured|provided)|api key (?:is )?(?:not set|unavailable))/i;
|
||||
|
||||
export type AdapterFailureRecoveryClassification =
|
||||
| { kind: "provider_quota"; retryAt: Date; parsedResetTime: boolean }
|
||||
| { kind: "configuration_incomplete" }
|
||||
| null;
|
||||
|
||||
function parseProviderQuotaClockReset(error: string, now: Date) {
|
||||
const match = error.match(
|
||||
/try again at\s+(\d{1,2})(?::(\d{2}))?\s*(?:([ap])\.?\s*m\.?)?(?:\s*\(([^)]+)\)|\s+([A-Z]{2,5}))?/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
|
||||
const hourValue = Number.parseInt(match[1] ?? "", 10);
|
||||
const minute = Number.parseInt(match[2] ?? "0", 10);
|
||||
const meridiem = (match[3] ?? "").toLowerCase();
|
||||
if (!Number.isInteger(hourValue)) return null;
|
||||
if (meridiem ? hourValue < 1 || hourValue > 12 : hourValue < 0 || hourValue > 23) return null;
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return null;
|
||||
|
||||
let hour = meridiem ? hourValue % 12 : hourValue;
|
||||
if (meridiem === "p") hour += 12;
|
||||
const timeZone = (match[4] ?? match[5])?.trim();
|
||||
if (!timeZone) {
|
||||
const retryAt = new Date(now);
|
||||
retryAt.setUTCHours(hour, minute, 0, 0);
|
||||
if (retryAt.getTime() <= now.getTime()) retryAt.setUTCDate(retryAt.getUTCDate() + 1);
|
||||
return retryAt;
|
||||
}
|
||||
|
||||
try {
|
||||
const wallClock = (date: Date) => Object.fromEntries(
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date).map((part) => [part.type, part.value]),
|
||||
);
|
||||
const nowParts = wallClock(now);
|
||||
const buildRetryAt = (dayOffset: number) => {
|
||||
const targetDay = new Date(Date.UTC(
|
||||
Number(nowParts.year),
|
||||
Number(nowParts.month) - 1,
|
||||
Number(nowParts.day) + dayOffset,
|
||||
hour,
|
||||
minute,
|
||||
));
|
||||
let candidate = targetDay;
|
||||
const targetMs = targetDay.getTime();
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const actual = wallClock(candidate);
|
||||
const actualMs = Date.UTC(
|
||||
Number(actual.year),
|
||||
Number(actual.month) - 1,
|
||||
Number(actual.day),
|
||||
Number(actual.hour),
|
||||
Number(actual.minute),
|
||||
);
|
||||
const adjustment = targetMs - actualMs;
|
||||
if (adjustment === 0) break;
|
||||
candidate = new Date(candidate.getTime() + adjustment);
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
const sameDay = buildRetryAt(0);
|
||||
return sameDay.getTime() > now.getTime() ? sameDay : buildRetryAt(1);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyAdapterFailureForRecovery(
|
||||
latestRun: Pick<NonNullable<LatestIssueRun>, "error" | "errorCode" | "resultJson">,
|
||||
now = new Date(),
|
||||
): AdapterFailureRecoveryClassification {
|
||||
if (
|
||||
latestRun.errorCode !== "adapter_failed" &&
|
||||
latestRun.errorCode !== "provider_quota" &&
|
||||
latestRun.errorCode !== "configuration_incomplete"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const resultJson = parseObject(latestRun.resultJson);
|
||||
const error = [latestRun.errorCode ?? "", latestRun.error ?? "", JSON.stringify(resultJson)].join("\n");
|
||||
if (latestRun.errorCode === "configuration_incomplete" || CONFIGURATION_INCOMPLETE_ERROR_RE.test(error)) {
|
||||
return { kind: "configuration_incomplete" };
|
||||
}
|
||||
if (latestRun.errorCode !== "provider_quota" && !PROVIDER_QUOTA_ERROR_RE.test(error)) return null;
|
||||
|
||||
const persistedRetryAt = readNonEmptyString(resultJson.retryNotBefore) ??
|
||||
readNonEmptyString(resultJson.transientRetryNotBefore) ??
|
||||
readNonEmptyString(resultJson.providerQuotaRetryNotBefore);
|
||||
const parsedPersistedRetryAt = persistedRetryAt ? new Date(persistedRetryAt) : null;
|
||||
if (parsedPersistedRetryAt && !Number.isNaN(parsedPersistedRetryAt.getTime()) && parsedPersistedRetryAt > now) {
|
||||
return { kind: "provider_quota", retryAt: parsedPersistedRetryAt, parsedResetTime: true };
|
||||
}
|
||||
|
||||
const parsedClockReset = parseProviderQuotaClockReset(error, now);
|
||||
if (parsedClockReset) {
|
||||
return { kind: "provider_quota", retryAt: parsedClockReset, parsedResetTime: true };
|
||||
}
|
||||
return {
|
||||
kind: "provider_quota",
|
||||
retryAt: new Date(now.getTime() + PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS),
|
||||
parsedResetTime: false,
|
||||
};
|
||||
}
|
||||
|
||||
type ContinuationRetryClassification = {
|
||||
kind: "transient_infra" | "non_retryable" | "default";
|
||||
|
|
@ -3210,6 +3329,139 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
return updated;
|
||||
}
|
||||
|
||||
async function persistAdapterFailureRecoveryClassification(
|
||||
latestRun: NonNullable<LatestIssueRun>,
|
||||
classification: NonNullable<AdapterFailureRecoveryClassification>,
|
||||
): Promise<NonNullable<LatestIssueRun>> {
|
||||
const classifiedRun = withAdapterFailureRecoveryClassification(latestRun, classification);
|
||||
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
errorCode: classifiedRun.errorCode,
|
||||
resultJson: parseObject(classifiedRun.resultJson),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, latestRun.id));
|
||||
|
||||
return classifiedRun;
|
||||
}
|
||||
|
||||
function withAdapterFailureRecoveryClassification(
|
||||
latestRun: NonNullable<LatestIssueRun>,
|
||||
classification: NonNullable<AdapterFailureRecoveryClassification>,
|
||||
): NonNullable<LatestIssueRun> {
|
||||
const resultJson = parseObject(latestRun.resultJson);
|
||||
const providerQuotaMetadata = classification.kind === "provider_quota"
|
||||
? {
|
||||
errorFamily: "provider_quota",
|
||||
retryNotBefore: classification.retryAt.toISOString(),
|
||||
transientRetryNotBefore: classification.retryAt.toISOString(),
|
||||
providerQuotaRetryNotBefore: classification.retryAt.toISOString(),
|
||||
}
|
||||
: { errorFamily: "configuration_incomplete" };
|
||||
const errorCode = classification.kind;
|
||||
|
||||
return {
|
||||
...latestRun,
|
||||
errorCode,
|
||||
resultJson: {
|
||||
...resultJson,
|
||||
...providerQuotaMetadata,
|
||||
recoveryClassification: errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function scheduleProviderQuotaRecoveryMonitor(input: {
|
||||
issue: typeof issues.$inferSelect;
|
||||
latestRun: NonNullable<LatestIssueRun>;
|
||||
classification: Extract<NonNullable<AdapterFailureRecoveryClassification>, { kind: "provider_quota" }>;
|
||||
}) {
|
||||
if (input.issue.status !== "in_progress" && input.issue.status !== "in_review") return null;
|
||||
|
||||
const targetAgentId = getAdapterFailureRecoveryTargetAgentId(input.issue);
|
||||
if (!targetAgentId || input.latestRun.agentId !== targetAgentId) return null;
|
||||
|
||||
const previousPolicy = normalizeIssueExecutionPolicy(input.issue.executionPolicy ?? null);
|
||||
const retryTargetDescription = input.issue.status === "in_review"
|
||||
? "the active review participant"
|
||||
: "the original assignee";
|
||||
const policy = {
|
||||
...(previousPolicy ?? { mode: "normal" as const, commentRequired: true, stages: [] }),
|
||||
monitor: {
|
||||
nextCheckAt: input.classification.retryAt.toISOString(),
|
||||
notes: input.classification.parsedResetTime
|
||||
? `Provider usage quota reached; retry ${retryTargetDescription} at the provider reset time.`
|
||||
: `Provider usage quota reached; retry ${retryTargetDescription} after the default recovery backoff.`,
|
||||
scheduledBy: "assignee" as const,
|
||||
kind: "external_service" as const,
|
||||
serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME,
|
||||
externalRef: input.latestRun.id,
|
||||
timeoutAt: null,
|
||||
maxAttempts: null,
|
||||
recoveryPolicy: "wake_owner" as const,
|
||||
},
|
||||
};
|
||||
const transition = applyIssueMonitorPolicyTransition({
|
||||
issue: input.issue,
|
||||
policy,
|
||||
previousPolicy,
|
||||
requestedStatus: input.issue.status,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: null, userId: null },
|
||||
monitorExplicitlyUpdated: true,
|
||||
});
|
||||
const updated = await issuesSvc.update(input.issue.id, {
|
||||
...transition.patch,
|
||||
executionPolicy: policy,
|
||||
});
|
||||
if (!updated) return null;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "recovery",
|
||||
agentId: null,
|
||||
runId: input.latestRun.id,
|
||||
action: "issue.monitor_scheduled",
|
||||
entityType: "issue",
|
||||
entityId: input.issue.id,
|
||||
details: {
|
||||
identifier: input.issue.identifier,
|
||||
source: "recovery.provider_quota",
|
||||
latestRunId: input.latestRun.id,
|
||||
errorCode: "provider_quota",
|
||||
nextCheckAt: input.classification.retryAt.toISOString(),
|
||||
parsedResetTime: input.classification.parsedResetTime,
|
||||
targetAgentId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function getAdapterFailureRecoveryTargetAgentId(issue: typeof issues.$inferSelect) {
|
||||
if (issue.status !== "in_review") return issue.assigneeAgentId;
|
||||
|
||||
const pendingExecutionState = parseIssueExecutionState(issue.executionState);
|
||||
const participant = pendingExecutionState?.status === "pending"
|
||||
? pendingExecutionState.currentParticipant
|
||||
: null;
|
||||
return participant?.type === "agent" ? participant.agentId : null;
|
||||
}
|
||||
|
||||
function hasPendingProviderQuotaRecoveryMonitor(
|
||||
issue: typeof issues.$inferSelect,
|
||||
latestRun: LatestIssueRun,
|
||||
now: Date,
|
||||
) {
|
||||
if (!latestRun || !issue.monitorNextCheckAt || issue.monitorNextCheckAt.getTime() <= now.getTime()) return false;
|
||||
const monitor = parseObject(parseObject(issue.executionPolicy).monitor);
|
||||
return readNonEmptyString(monitor.serviceName) === PROVIDER_QUOTA_MONITOR_SERVICE_NAME &&
|
||||
readNonEmptyString(monitor.externalRef) === latestRun.id;
|
||||
}
|
||||
|
||||
async function reconcileStrandedAssignedIssues(opts?: { issueCreatedAtGte?: Date | null }) {
|
||||
const candidates = await db
|
||||
.select()
|
||||
|
|
@ -3237,6 +3489,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
reviewParticipantRequeued: 0,
|
||||
escalated: 0,
|
||||
waitingOnReviewResolved: 0,
|
||||
providerQuotaMonitored: 0,
|
||||
recentProgressExempted: 0,
|
||||
skipped: 0,
|
||||
issueIds: [] as string[],
|
||||
|
|
@ -3287,11 +3540,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
continue;
|
||||
}
|
||||
|
||||
const latestRun = await getLatestIssueRun(issue.companyId, issue.id);
|
||||
let latestRun = await getLatestIssueRun(issue.companyId, issue.id);
|
||||
if (latestRun?.status === "succeeded" && await hasPersistedDurableWaitPath(issue)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const recoveryNow = new Date();
|
||||
const participantLatestRunForRecovery = issue.status === "in_review" && participantAgentId
|
||||
? await getLatestIssueRunForAgent(issue.companyId, issue.id, participantAgentId)
|
||||
: null;
|
||||
const providerQuotaMonitorRun = issue.status === "in_review"
|
||||
? participantLatestRunForRecovery
|
||||
: latestRun;
|
||||
if (hasPendingProviderQuotaRecoveryMonitor(issue, providerQuotaMonitorRun, recoveryNow)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) {
|
||||
const updated = await escalateStrandedRecoveryIssueInPlace({
|
||||
issue,
|
||||
|
|
@ -3307,6 +3571,51 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
continue;
|
||||
}
|
||||
|
||||
const adapterFailureClassification = issue.status !== "in_review" && latestRun && isUnsuccessfulTerminalIssueRun(latestRun)
|
||||
? classifyAdapterFailureForRecovery(latestRun, recoveryNow)
|
||||
: null;
|
||||
if (latestRun && adapterFailureClassification) {
|
||||
const targetAgentId = getAdapterFailureRecoveryTargetAgentId(issue);
|
||||
if (!targetAgentId || latestRun.agentId !== targetAgentId) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (adapterFailureClassification.kind === "provider_quota") {
|
||||
const monitored = await scheduleProviderQuotaRecoveryMonitor({
|
||||
issue,
|
||||
latestRun,
|
||||
classification: adapterFailureClassification,
|
||||
});
|
||||
if (monitored) {
|
||||
latestRun = await persistAdapterFailureRecoveryClassification(latestRun, adapterFailureClassification);
|
||||
result.providerQuotaMonitored += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
continue;
|
||||
}
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
} else {
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: issue.status as StrandedPreviousStatus,
|
||||
latestRun,
|
||||
recoveryCause: "configuration_incomplete",
|
||||
comment:
|
||||
"Paperclip classified the latest adapter failure as `configuration_incomplete`. " +
|
||||
"Moving the issue to `blocked` with the configuration fix recorded instead of creating a recovery takeover.",
|
||||
});
|
||||
if (updated) {
|
||||
latestRun = await persistAdapterFailureRecoveryClassification(latestRun, adapterFailureClassification);
|
||||
result.escalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const acceptedContinuationInteraction = await getLatestAcceptedContinuationInteraction(issue.companyId, issue.id);
|
||||
const acceptedInteractionResolvedAt = acceptedContinuationInteraction
|
||||
? acceptedContinuationInteraction.resolvedAt ?? acceptedContinuationInteraction.updatedAt
|
||||
|
|
@ -3406,11 +3715,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const participantLatestRun = await getLatestIssueRunForAgent(
|
||||
issue.companyId,
|
||||
issue.id,
|
||||
participantAgentId,
|
||||
);
|
||||
const participantLatestRun = participantLatestRunForRecovery;
|
||||
|
||||
if (!participantLatestRun || !isTerminalIssueRun(participantLatestRun)) {
|
||||
if (!agentInvokable) {
|
||||
|
|
@ -3434,6 +3739,52 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
continue;
|
||||
}
|
||||
|
||||
const participantAdapterFailureClassification = isUnsuccessfulTerminalIssueRun(participantLatestRun)
|
||||
? classifyAdapterFailureForRecovery(participantLatestRun, recoveryNow)
|
||||
: null;
|
||||
if (participantAdapterFailureClassification?.kind === "provider_quota") {
|
||||
const monitored = await scheduleProviderQuotaRecoveryMonitor({
|
||||
issue,
|
||||
latestRun: participantLatestRun,
|
||||
classification: participantAdapterFailureClassification,
|
||||
});
|
||||
if (monitored) {
|
||||
latestRun = await persistAdapterFailureRecoveryClassification(
|
||||
participantLatestRun,
|
||||
participantAdapterFailureClassification,
|
||||
);
|
||||
result.providerQuotaMonitored += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (participantAdapterFailureClassification?.kind === "configuration_incomplete") {
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_review",
|
||||
latestRun: participantLatestRun,
|
||||
recoveryCause: "configuration_incomplete",
|
||||
recoveryOwnerAgentId: participantAgentId,
|
||||
comment:
|
||||
"Paperclip classified the active review participant's latest adapter failure as " +
|
||||
"`configuration_incomplete`. Moving the issue to `blocked` with the configuration fix " +
|
||||
"recorded instead of repeatedly requeueing the reviewer.",
|
||||
});
|
||||
if (updated) {
|
||||
latestRun = await persistAdapterFailureRecoveryClassification(
|
||||
participantLatestRun,
|
||||
participantAdapterFailureClassification,
|
||||
);
|
||||
result.escalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentInvokable) {
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
|
|
|
|||
Loading…
Reference in New Issue