mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
fix: recover transient workspace bootstrap scans (#13481)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The control plane prepares task workspaces before it starts an agent. > - Workspace preparation reads Git state so it can preserve edits and exclude private files. > - A failed scan was treated as a non-Git folder and lost its actual failure code. > - The resulting generic setup failure could not recover, even when the cause was temporary. > - This pull request keeps the cause and uses the existing bounded retry schedule before provider startup. > - Tasks can recover without human intervention, while permanent failures and exhausted retries stop with useful guidance. ## Linked Issues or Issue Description **What happened?** A Git scan error during managed repository preparation became `Configured repository folder is not a Git checkout`, followed by generic `setup_failed`. The agent never started. Generic recovery could not distinguish a temporary timeout from a bad workspace configuration. **Expected behavior** Keep the closed scan error code. Retry temporary timeouts and queue saturation under the existing shared budget. Preserve edits, exclusions, ownership, and pause gates. Stop permanent failures and exhausted retries with a specific explanation. Do not replay historical generic setup failures. **Steps to reproduce** 1. Configure a task project with a local Git source that must be copied into its managed repositories. 2. Make the ignored-file scan exceed its timeout before the agent starts. 3. Before this fix, the snapshot returns null and the run ends as non-retryable `setup_failed`. 4. Use the disposable browser fixture in `tests/e2e/workspace-bootstrap/README.md` to inject real timeouts and test the full recovery path. **Paperclip version or commit** Reproduced against `4510bf7c9e2fcbeb043445850928b5dcb79908ca`. **Deployment mode** Built from source. The defect is in core workspace setup, not a specific model provider. Related work: Refs #13442 (managed repository preparation), Refs #11572 (bounded Git scheduler), Refs #12997 (separate adapter startup retry work), Refs #13469 (separate terminal-workspace scan performance work). ## What Changed - Return the non-Git fallback only for repository discovery. Propagate failed scans of a confirmed repository. - Replace full ignored status output with an ignored-only directory listing. Preserve NUL-delimited paths and exclusions. - Preserve typed, sanitized scan errors through workspace preparation and persist pre-provider failure details. - Retry only timeouts and queue saturation, using the existing durable two-retry budget and issue gates. Prevent generic recovery from adding another budget. - Show workspace-specific failure copy and actionable exhausted-recovery notices. - Add red-green unit tests, real-database restart and retry-boundary tests, and opt-in browser acceptance fixtures with real Git subprocess timeouts. - Document the recovery contract and browser verification procedure. ## Verification - Red: injected scan failures returned null instead of rejecting; setup lost the timeout code; task-thread and recovery notices had generic copy. - Green: 119 focused adapter/backend tests, 20 recovery-boundary tests, and 136 task-thread tests. - `pnpm -r typecheck` — passed. - `pnpm build` — passed on the final production code. - `pnpm check:token-gates` — passed. - The initial local `pnpm test:run` overlapped source edits and was interrupted after two late-added assertions saw pre-fix behavior; it is not counted as a green full run. A fresh final-head run passed all 229 tests across the six affected adapter/backend/UI suites. The clean latest-head CI full test matrix passed: all five general-server shards, all five serialized-server shards, and all three general-workspace shards. - Latest-head CI also passed all three browser shards and their aggregate gate, typecheck and release registry, build, runner verification, canary dry run, policy, Docker context integrity, and security gates. Greptile: 5/5, with the review thread resolved. - Browser: created a task in a disposable instance. A real Git timeout scheduled recovery, the next run completed through the run-scoped API without manual Retry, and Done survived reload. The deterministic process worker checked preserved source edits and excluded private files; no model calls were made. - `WORKSPACE_BOOTSTRAP_TEST_URL=<disposable-instance-url> pnpm exec playwright test --config tests/e2e/workspace-bootstrap/playwright.config.ts` — 2 passed (3.6 minutes). The persistent case made exactly three failed attempts, never started the worker, showed the cause-specific notice, stayed stopped for another scheduler tick, and retained Blocked after reload. - Extra red-green coverage: 50 recovery tests passed after fixing an exhausted-bootstrap classification that incorrectly implied unknown provider actions. Missing or uncertain evidence still retains the safety hold. - Verified the documented Git executable override during repository seeding. ## Risks - A confirmed repository scan failure now fails closed instead of falling back to directory sync. This prevents unfiltered copying but makes previously hidden errors visible. - Temporary host problems can create up to two additional setup attempts, 30 seconds apart. Permanent scan errors do not auto-retry. Generic recovery cannot reset this budget. - The durable retry path still enforces ownership, pause, and work eligibility. Integration tests cover restart, duplicate promotion, pause, exhaustion, and non-retryable categories. - No schema migration, new runtime setting, new retry budget, production deployment, or historical task replay. ## Model Used OpenAI Codex, GPT-5-based coding agent, with reasoning, repository tools, shell execution, and browser testing. The exact deployment model ID and context-window size are not exposed in this session. ## 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>
This commit is contained in:
1 parent
cceeb0aa66
commit
f4cdc7b231
20 files changed
+542
-89
No files matched your search
@@ -1,12 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, realpath, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, createDb, issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { runLocalGit, setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync";
|
||||
import { WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
||||
@@ -44,6 +46,103 @@ suite("task project repository provisioning", () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
afterEach(async () => {
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
setExpensiveWorkspaceGitExecutor(null);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: "workspace_git_scan_timeout", scenario: "temporary", retryable: true },
|
||||
{ code: "workspace_git_scan_saturated", scenario: "temporary", retryable: true },
|
||||
{ code: "workspace_git_scan_timeout", scenario: "exhausted", retryable: true },
|
||||
{ code: "workspace_git_scan_timeout", scenario: "paused", retryable: true },
|
||||
{ code: "workspace_git_scan_output_limit", scenario: "permanent", retryable: false },
|
||||
{ code: "workspace_git_scan_cancelled", scenario: "cancelled", retryable: false },
|
||||
{ code: "workspace_git_scan_failed", scenario: "permanent", retryable: false },
|
||||
] as const)("handles $scenario $code during local-source preparation", async ({ code, scenario, retryable }) => {
|
||||
const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const source = path.join(root, companyId, "source");
|
||||
await mkdir(source, { recursive: true });
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd: source, stdio: "ignore" });
|
||||
git("init", "-b", "main");
|
||||
await writeFile(path.join(source, "README.md"), "committed");
|
||||
await writeFile(path.join(source, ".gitignore"), "private.secret\n");
|
||||
git("add", ".");
|
||||
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed");
|
||||
await writeFile(path.join(source, "README.md"), "preserved dirty work");
|
||||
await writeFile(path.join(source, "private.secret"), "must not copy");
|
||||
await db.insert(companies).values({ id: companyId, name: "Bootstrap recovery", issuePrefix: `R${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" });
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Local source", status: "in_progress" });
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{ id: randomUUID(), companyId, projectId, name: "Anchor", sourceType: "local_path", cwd: source, isPrimary: true, createdAt: new Date(Date.now() - 1000) },
|
||||
{ id: randomUUID(), companyId, projectId, name: "Source copy", sourceType: "git_repo", repoUrl: pathToFileURL(source).href, cwd: source, isPrimary: false },
|
||||
]);
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Test", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} });
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Recover startup and use existing work", status: "todo", assigneeAgentId: agentId });
|
||||
let inject = true;
|
||||
const canonicalSource = await realpath(source);
|
||||
setExpensiveWorkspaceGitExecutor(async (input) => {
|
||||
if (inject && (input.localDir === source || input.localDir === canonicalSource) && input.operation === "adapter_sync.ignored_files") {
|
||||
inject = scenario === "exhausted";
|
||||
throw new WorkspaceGitScanError(code, "Injected temporary scan failure");
|
||||
}
|
||||
return runLocalGit(input.localDir, [...input.args]);
|
||||
});
|
||||
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId, projectId } });
|
||||
await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.errorCode).toBe(code), { timeout: 15_000 });
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(0);
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
if (!retryable) {
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, run!.id))).toHaveLength(0);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]?.status).toBe("blocked");
|
||||
return;
|
||||
}
|
||||
let retry: typeof heartbeatRuns.$inferSelect;
|
||||
await vi.waitFor(async () => {
|
||||
const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, run!.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
retry = rows[0]!;
|
||||
expect(retry.status).toBe("scheduled_retry");
|
||||
expect(retry.scheduledRetryAttempt).toBe(1);
|
||||
});
|
||||
// Recreate the service as on restart; durable scheduling must not need the old closure.
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
heartbeat = heartbeatService(db);
|
||||
if (scenario === "paused") await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
|
||||
await db.update(heartbeatRuns).set({ scheduledRetryAt: new Date(Date.now() - 1000) }).where(eq(heartbeatRuns.id, retry!.id));
|
||||
await heartbeat.promoteDueScheduledRetries();
|
||||
await heartbeat.promoteDueScheduledRetries();
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
if (scenario === "paused") {
|
||||
expect((await heartbeat.getRun(retry!.id))?.status).toBe("cancelled");
|
||||
expect(execute.mock.calls.filter(([input]) => input.agent.id === agentId)).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
if (scenario === "exhausted") {
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
expect((await heartbeat.getRun(retry!.id))?.errorCode).toBe(code);
|
||||
const [last] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, retry!.id));
|
||||
expect(last).toMatchObject({ status: "scheduled_retry", scheduledRetryAttempt: 2 });
|
||||
await db.update(heartbeatRuns).set({ scheduledRetryAt: new Date(Date.now() - 1000) }).where(eq(heartbeatRuns.id, last!.id));
|
||||
await heartbeat.promoteDueScheduledRetries();
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
expect((await heartbeat.getRun(last!.id))?.errorCode).toBe(code);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(3);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ status: "blocked", assigneeAgentId: agentId });
|
||||
expect(execute.mock.calls.filter(([input]) => input.agent.id === agentId)).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
await vi.waitFor(async () => expect((await heartbeat.getRun(retry!.id))?.status).toBe("succeeded"), { timeout: 15_000 });
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === retry!.id)).toHaveLength(1);
|
||||
const [task] = await db.select().from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)));
|
||||
expect(task).toMatchObject({ status: "done", assigneeAgentId: agentId });
|
||||
const copies = (execute.mock.calls.find(([input]) => input.runId === retry!.id)![0].context.paperclipWorkspaces as Array<{ cwd: string }>).filter((hint) => hint.cwd.includes(".paperclip-repositories"));
|
||||
expect(copies.length).toBeGreaterThan(0);
|
||||
expect(await readFile(path.join(copies[0]!.cwd, "README.md"), "utf8")).toBe("preserved dirty work");
|
||||
await expect(readFile(path.join(copies[0]!.cwd, "private.secret"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(path.join(source, "README.md"), "utf8")).toBe("preserved dirty work");
|
||||
}, 40_000);
|
||||
|
||||
it.each([1, 2])("gives a task all %i repositories without any configured local folders", async (count) => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
@@ -29,6 +29,7 @@ export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-
|
||||
import { buildExecutionContinuation } from "./execution-continuation.js";
|
||||
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { PROJECT_REPOSITORIES_DIR, readGitWorkspaceSnapshot } from "@paperclipai/adapter-utils/git-workspace-sync";
|
||||
import { isWorkspaceGitScanError, WorkspaceGitScanError, WORKSPACE_GIT_SCAN_ERROR_CODES } from "./workspace-git-operation-scheduler.js";
|
||||
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { initializeRunIdentity, explicitOperatorRunIdentity } from "./run-identity.js";
|
||||
import {
|
||||
@@ -761,6 +762,9 @@ export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS = [
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0;
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure";
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry";
|
||||
function isTransientWorkspaceGitScanCode(code: string | null | undefined): boolean {
|
||||
return code === WORKSPACE_GIT_SCAN_ERROR_CODES.timeout || code === WORKSPACE_GIT_SCAN_ERROR_CODES.saturated;
|
||||
}
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS =
|
||||
BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length;
|
||||
export {
|
||||
@@ -2496,11 +2500,13 @@ async function materializeManagedProjectWorkspace(
|
||||
error: reason,
|
||||
used: auth ? { source: auth.source, secretName: auth.secretName } : null,
|
||||
});
|
||||
throw new Error(
|
||||
scrubGitCredentialText(
|
||||
`Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`,
|
||||
),
|
||||
const message = scrubGitCredentialText(
|
||||
`Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`,
|
||||
);
|
||||
// Preserve the closed failure code without copying subprocess output or
|
||||
// credentials into the durable run. Setup recovery needs the actual cause.
|
||||
if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -15243,6 +15249,7 @@ export function heartbeatService(
|
||||
: baseSchedule;
|
||||
|
||||
const requiresIssueGate =
|
||||
isTransientWorkspaceGitScanCode(run.errorCode) ||
|
||||
hasConversationContinuationPolicy(run.resultJson) ||
|
||||
retryReason === AI_CONNECTION_BUSY_RETRY_REASON ||
|
||||
retryReason === MAX_TURN_CONTINUATION_RETRY_REASON ||
|
||||
@@ -25282,7 +25289,9 @@ export function heartbeatService(
|
||||
);
|
||||
const nonRetryablePreflightCode =
|
||||
nonRetryablePreflightFailureCode(outerErr);
|
||||
const workspaceGitScanFailure = isWorkspaceGitScanError(outerErr) ? outerErr : null;
|
||||
const setupFailureErrorCode =
|
||||
workspaceGitScanFailure?.code ??
|
||||
workspaceValidationSetupFailure?.code ??
|
||||
configurationIncompleteSetupFailure?.code ??
|
||||
(unresolvedBaseRefSetupFailure ||
|
||||
@@ -25301,6 +25310,13 @@ export function heartbeatService(
|
||||
// action, so it is persisted even when the agent lookup failed and the
|
||||
// agent-scoped stop metadata cannot be merged in.
|
||||
const setupFailureDetails =
|
||||
(workspaceGitScanFailure ? {
|
||||
workspaceGitScan: {
|
||||
code: workspaceGitScanFailure.code,
|
||||
phase: "workspace_setup",
|
||||
retryable: isTransientWorkspaceGitScanCode(workspaceGitScanFailure.code),
|
||||
},
|
||||
} : null) ??
|
||||
workspaceValidationSetupFailure?.resultJson ??
|
||||
configurationIncompleteSetupFailure?.resultJson ??
|
||||
(unresolvedBaseRefSetupFailure
|
||||
@@ -25403,9 +25419,12 @@ export function heartbeatService(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
await scheduleInteractionContinuationInfrastructureRetryIfEligible(
|
||||
livenessRun,
|
||||
failedAgent,
|
||||
// No provider work began. Retry temporary host scan failures with
|
||||
// the existing durable failure budget, before releasing execution.
|
||||
// Generic recovery must not grant a second budget on exhaustion.
|
||||
await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
|
||||
? scheduleBoundedRetryForRun(livenessRun, failedAgent)
|
||||
: scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
|
||||
).catch((retryError) => {
|
||||
logger.warn(
|
||||
{ err: retryError, runId: livenessRun.id },
|
||||
|
||||
@@ -9,6 +9,17 @@ const stopped = {
|
||||
},
|
||||
};
|
||||
|
||||
it.each(["workspace_git_scan_timeout", "workspace_git_scan_saturated"])("does not invent unknown provider actions after exhausted %s bootstrap retries", (errorCode) => {
|
||||
const run = { runtimeMode: "legacy", status: "failed", errorCode, scheduledRetryAttempt: 2,
|
||||
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } };
|
||||
expect(legacyExecutionNeedsReconciliation(run)).toBe(false);
|
||||
expect(legacyExecutionNeedsReconciliation({ ...run, resultJson: {} })).toBe(true);
|
||||
expect(legacyExecutionNeedsReconciliation({ ...run, resultJson: {
|
||||
executionRecovery: { kind: "bootstrap", providerWorkStarted: true },
|
||||
} })).toBe(true);
|
||||
expect(legacyExecutionNeedsReconciliation({ ...run, errorCode: "setup_failed" })).toBe(true);
|
||||
});
|
||||
|
||||
it("permits subscription waits only with explicit evidence that provider work never started", () => {
|
||||
const waiting = {
|
||||
runtimeMode: "legacy", status: "cancelled", errorCode: "ai_connection_busy", scheduledRetryAttempt: 12,
|
||||
|
||||
@@ -37,6 +37,11 @@ export function legacyExecutionNeedsReconciliation(
|
||||
evidence?.kind === "ai_connection_wait" && evidence.providerWorkStarted === false) return false;
|
||||
if (run.status === "cancelled" && run.errorCode === "workspace_busy" &&
|
||||
evidence?.kind === "workspace_wait" && evidence.providerWorkStarted === false) return false;
|
||||
// Setup owns the bounded retry budget for temporary workspace scans. Its
|
||||
// exhaustion needs workspace repair, not reconciliation of provider actions
|
||||
// that the bootstrap evidence proves never started. Keep unknown outcomes held.
|
||||
if ((run.errorCode === "workspace_git_scan_timeout" || run.errorCode === "workspace_git_scan_saturated") &&
|
||||
evidence?.kind === "bootstrap" && evidence.providerWorkStarted === false) return false;
|
||||
if (executionFailureRetryCount(run) >= 2) return true;
|
||||
return !(
|
||||
evidence?.kind === "bootstrap" && evidence.providerWorkStarted === false
|
||||
|
||||
@@ -5,6 +5,9 @@ const run = (errorCode: string | null) =>
|
||||
({ errorCode } as unknown as Parameters<typeof classifyContinuationFailure>[0]);
|
||||
|
||||
describe("pause durability: continuation retry classification", () => {
|
||||
it.each(["workspace_git_scan_timeout", "workspace_git_scan_saturated", "workspace_git_scan_failed", "workspace_git_scan_output_limit", "workspace_git_scan_cancelled"])("does not grant %s another recovery budget", (code) => {
|
||||
expect(classifyContinuationFailure(run(code))).toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
|
||||
});
|
||||
it("agent_paused is retryable so work resumes (Option A: Resume Continues Work)", () => {
|
||||
// Pause still emits errorCode agent_paused for observability, but it is NOT
|
||||
// non-retryable. On resume the agent becomes invokable again and this classifies
|
||||
|
||||
@@ -489,6 +489,13 @@ const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set<string>([
|
||||
// process starts. Known transient preflight failures use dedicated bounded
|
||||
// retry paths instead of generic issue continuation recovery.
|
||||
"setup_failed",
|
||||
// Setup owns the shared durable retry budget for temporary Git scans.
|
||||
// Generic continuation must not retry permanent failures or reset that budget.
|
||||
"workspace_git_scan_timeout",
|
||||
"workspace_git_scan_saturated",
|
||||
"workspace_git_scan_cancelled",
|
||||
"workspace_git_scan_output_limit",
|
||||
"workspace_git_scan_failed",
|
||||
"low_trust_isolation_unavailable",
|
||||
"low_trust_requires_isolated_workspace",
|
||||
"low_trust_boundary_mismatch",
|
||||
|
||||
@@ -76,6 +76,18 @@ describe("stranded recovery notice seeds", () => {
|
||||
});
|
||||
|
||||
describe("buildStrandedRecoveryEscalationNotice", () => {
|
||||
it("names a workspace timeout and its repair instead of claiming generic continuation", () => {
|
||||
const notice = buildStrandedRecoveryEscalationNotice({
|
||||
seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }),
|
||||
recoveryActionId: "scan-recovery",
|
||||
recoveryOwner: null,
|
||||
sourceRun: { id: "failed-run", status: "failed", errorCode: "workspace_git_scan_timeout" },
|
||||
});
|
||||
expect(notice.presentation.title).toBe("Workspace scan timed out");
|
||||
expect(notice.body).toContain("before the agent started");
|
||||
expect(notice.body).not.toContain("retried continuation");
|
||||
expect(JSON.stringify(notice.metadata)).toContain("repository access and server load");
|
||||
});
|
||||
const actionId = "6a2f8e64-6f5e-4b58-b7fd-111111111111";
|
||||
const owner = { id: "9b1c2d3e-4f50-4a61-8b72-222222222222", name: "CTO" };
|
||||
const sourceRun = {
|
||||
|
||||
@@ -47,6 +47,14 @@ const STRANDED_RECOVERY_NOTICE_TITLES_BY_RUN_ERROR_CODE: Record<string, string>
|
||||
acpx_auth_required: "Error: agent login required",
|
||||
};
|
||||
|
||||
const WORKSPACE_SCAN_NOTICES: Record<string, { title: string; nextAction: string }> = {
|
||||
workspace_git_scan_timeout: { title: "Workspace scan timed out", nextAction: "Check repository access and server load, then retry the task." },
|
||||
workspace_git_scan_saturated: { title: "Workspace scan queue is full", nextAction: "Check server load and the workspace scan queue, then retry the task." },
|
||||
workspace_git_scan_output_limit: { title: "Workspace scan exceeded its limit", nextAction: "Check the repository size and workspace scan output limit before retrying the task." },
|
||||
workspace_git_scan_failed: { title: "Workspace scan failed", nextAction: "Inspect the failed run and check repository access and integrity before retrying the task." },
|
||||
workspace_git_scan_cancelled: { title: "Workspace scan was cancelled", nextAction: "Inspect why workspace preparation was cancelled before retrying the task." },
|
||||
};
|
||||
|
||||
export function buildImmediateExecutionPathRecoveryNoticeSeed(input: {
|
||||
status: "todo" | "in_progress";
|
||||
}): StrandedRecoveryNoticeSeed {
|
||||
@@ -175,11 +183,17 @@ export function buildStrandedRecoveryEscalationNotice(input: {
|
||||
errorSummary?: string | null;
|
||||
} | null | undefined;
|
||||
}): StrandedRecoveryEscalationNotice {
|
||||
const workspaceScan = WORKSPACE_SCAN_NOTICES[input.sourceRun?.errorCode ?? ""];
|
||||
const seed = workspaceScan ? {
|
||||
...workspaceScan,
|
||||
body: `Paperclip could not prepare the workspace before the agent started. Automatic recovery could not continue. ${workspaceScan.nextAction}`,
|
||||
tone: "danger" as const,
|
||||
} : input.seed;
|
||||
const fallbackBody = input.fallbackBody?.trim();
|
||||
const body = input.seed?.body ?? (fallbackBody || DEFAULT_STRANDED_RECOVERY_NOTICE_BODY);
|
||||
const body = seed?.body ?? (fallbackBody || DEFAULT_STRANDED_RECOVERY_NOTICE_BODY);
|
||||
const title =
|
||||
STRANDED_RECOVERY_NOTICE_TITLES_BY_RUN_ERROR_CODE[input.sourceRun?.errorCode?.trim() ?? ""] ??
|
||||
input.seed?.title ??
|
||||
seed?.title ??
|
||||
STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE[input.recoveryCause ?? ""] ??
|
||||
DEFAULT_STRANDED_RECOVERY_NOTICE_TITLE;
|
||||
|
||||
@@ -193,7 +207,7 @@ export function buildStrandedRecoveryEscalationNotice(input: {
|
||||
),
|
||||
keyValueRow(
|
||||
"Next action",
|
||||
input.seed?.nextAction ?? (input.recoveryOwner
|
||||
seed?.nextAction ?? (input.recoveryOwner
|
||||
? "The recovery owner should either restore a live execution path or record the manual resolution on the source issue"
|
||||
: "Inspect the evidence, then retry the original owner, explicitly reassign, repair the execution path, or record an intentional resolution"),
|
||||
),
|
||||
@@ -215,7 +229,7 @@ export function buildStrandedRecoveryEscalationNotice(input: {
|
||||
|
||||
return {
|
||||
body,
|
||||
presentation: systemNoticePresentation({ tone: input.seed?.tone ?? "danger", title }),
|
||||
presentation: systemNoticePresentation({ tone: seed?.tone ?? "danger", title }),
|
||||
metadata: {
|
||||
version: 1,
|
||||
sourceRunId: input.sourceRun?.id ?? null,
|
||||
|
||||
Reference in new issue
Block a user