mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A task runs in an execution environment. A managed sandbox
environment gets a lease from a sandbox provider (for example Daytona)
before the agent starts.
> - When the provider answered sandbox create with a temporary error
(HTTP 502), the run failed as the generic `setup_failed`.
> - `setup_failed` is the fail-closed code for unknown setup errors. It
gets no automatic retry, so the task went to `blocked` at once. The task
showed only "Couldn't finish this attempt".
> - A user had to find and press Retry. The Retry then worked, which
shows that the failure was temporary.
> - This pull request classifies temporary provider refusals during
lease acquisition from structured fields. It retries them within the
existing bounded bootstrap budget, and it explains the block when the
retries are used up.
> - The benefit is that a short provider outage no longer stops a task.
When the outage continues, the task shows the cause and a Retry that
works.
## Linked Issues or Issue Description
**What happened?**
A task was assigned to an agent that uses a managed Daytona sandbox
environment. Sandbox creation returned `HTTP 502`, and the cleanup of
the provisional sandbox could not be confirmed. The run failed with
`setup_failed` and the error `Failed to acquire lease for environment
"Paperclip Sandbox" (sandbox): Daytona sandbox creation failed (HTTP
502); cleanup could not be confirmed for paperclip-create-…`. Three
seconds later the task was `blocked`. No automatic retry was scheduled.
The task showed no specific reason. A manual Retry started a new run
that completed normally.
**Expected behavior**
A temporary provider error (HTTP 5xx, 429, timeout) before the agent
starts must retry automatically with bounded backoff. The task must show
why it waits. If the retries are used up, the task must show the cause,
a next action, and a Retry that works on the first click.
**Steps to reproduce**
1. Configure an agent with a sandbox environment.
2. Make the provider return HTTP 502 on sandbox create.
3. Assign a task to the agent.
4. See that the run fails with `setup_failed` and the task goes to
`blocked` with no retry.
**Paperclip version or commit**
`master` at `612da1e8e0`.
## What Changed
- Plugin SDK: an `environmentAcquireLease` error now carries a closed
diagnostic envelope (HTTP status or a closed errno code) across the
worker RPC. It takes the status of the create call only, not of the
compensating cleanup lookup. The existing creation-cleanup ownership
envelope is unchanged. New module: `environment-acquire-error.ts`. The
resume diagnostic helpers are shared.
- Server: new `EnvironmentProviderUnavailableError` and code
`environment_provider_unavailable`. The environment run orchestrator
sets it as the cause of `lease_acquire_failed` only when structured
evidence shows a transient status (408, 425, 429, 500, 502, 503, 504, or
a closed connection errno). It never parses message text. Saved-sandbox
resume and stopped-worker refusals keep their own codes.
- Setup failure (`failRunSetup`): a fresh, pre-dispatch run records
`environment_provider_unavailable` with a `providerFailure` (phase
`startup`, retryability `transient`). The run uses the bounded bootstrap
retry (30 s, then 60 s). An accepted interaction continuation keeps its
own continuation retry, as for `plugin_worker_unavailable`.
- Recovery: the new code is registered with the other bootstrap codes.
It is not sent to the generic continuation retry. It does not create an
execution-reconciliation hold, because no provider work started. On
exhaustion the stranded notice has the title "Sandbox provider was
temporarily unavailable" and a specific next action.
- UI: `ProviderFailureNotice` labels the `sandbox` provider as "Sandbox
provider". The task notice shows the provider failure, "Paperclip will
retry automatically" while a retry is scheduled, and Retry after
exhaustion.
- Docs: `doc/execution-semantics.md` describes the policy and the
sandbox ownership rule.
## Verification
Red before the fix, green after the fix:
- `server/src/__tests__/heartbeat-process-recovery.test.ts` › "transient
sandbox provider refusal during lease acquisition":
- "retries automatically and runs exactly one successful successor
without user action": the real heartbeat. The lease runtime returns a
Daytona 502 once, then delegates. Before the fix: one `failed` run and
no successor. After the fix: one `scheduled_retry` successor with no
hold and a `retry_scheduled` presentation. After promotion, exactly one
successor succeeds.
- "explains the block with a working Retry once automatic retries are
used up": three refusals. The task is `blocked` with a "Sandbox provider
was temporarily unavailable" notice. The presentation is `{ state:
"failed", canRetry: true, providerFailure: { httpStatus: 502 } }`, and
no execution-reconciliation hold exists. One `requestTaskRetry` is
accepted, and its run succeeds.
- `server/src/services/heartbeat/run-completion.test.ts` › "routes a …
sandbox provider lease refusal through bounded bootstrap recovery": the
real orchestrator wrapping. Only the fresh, structured 502 is retried. A
message-only 502, a 400, a run after provider dispatch, and a retained
native run stay `setup_failed`.
- `packages/plugins/sdk/tests/environment-acquire-error.test.ts`: the
real worker RPC. The test checks the create status (not the 404 cleanup
lookup), the errno, no provider text, the unchanged cleanup envelope,
and that forged envelopes are rejected.
Commands:
```sh
pnpm --filter @paperclipai/plugin-sdk exec vitest run tests/environment-acquire-error.test.ts tests/environment-creation-cleanup.test.ts tests/environment-resume-error.test.ts
cd server && npx vitest run src/__tests__/heartbeat-process-recovery.test.ts src/services/recovery/ src/services/heartbeat/run-completion.test.ts src/services/heartbeat/retries.test.ts src/services/legacy-execution-recovery.test.ts src/services/task-interruption-presentation.test.ts src/services/task-retry.test.ts src/__tests__/environment-runtime.test.ts src/__tests__/heartbeat-retry-scheduling.test.ts
cd packages/plugins/sandbox-providers/daytona && npx vitest run
pnpm --filter @paperclipai/plugin-sdk typecheck && pnpm --filter @paperclipai/server typecheck
```
I did not run a live Daytona 502 on staging. Re-check on staging after
deploy (see Risks).
## Risks
- Sandbox leaks or double creation: each retry is a new run with a new
durable creation record and a new provider name
(`paperclip-create-<operationId>`). The failed attempt stays owned by
its own record, and the existing creation reconciler settles it. A retry
never reuses or abandons it. While a provisional sandbox waits for
reconciliation, up to two more sandboxes can be created for the same
task. This is the same as a manual Retry today.
- Plugins built with an older SDK do not send the envelope. Their
refusals stay `setup_failed`, which is the current behavior.
- An in-process error with `status`/`statusCode` 500–504 or a connection
errno during lease acquisition is now retried twice. The budget is
bounded and shared with the other bootstrap codes.
- An RPC timeout on the host side is not classified by this change.
> 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
- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code as an agent with tool use (shell, file edits, test runs).
Extended reasoning was on.
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
20 KiB
TypeScript
273 lines
20 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, agentWakeupRequests, companies, createDb, applyPendingMigrations, heartbeatRuns, issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
|
import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { captureInterruptionQuery, seedInterruptionFixture } from "./helpers/task-interruption-fixture.js";
|
|
|
|
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TASK_INTERRUPTION_TEST_DATABASE_URL");
|
|
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const captureTaskInterruptionFailure = vi.hoisted(() => vi.fn());
|
|
vi.mock("../sentry.js", () => ({ captureTaskInterruptionFailure }));
|
|
|
|
describe.skipIf(!support.supported)("task interruption latest run query", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
if (externalDatabaseUrl) await applyPendingMigrations(externalDatabaseUrl);
|
|
database = externalDatabaseUrl ? { connectionString: externalDatabaseUrl, cleanup: async () => {} }
|
|
: await startEmbeddedPostgresTestDatabase("interruption-query-");
|
|
db = createDb(database.connectionString);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
|
|
|
it("redacts workspace preparation errors on task detail and list reads", async () => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "a".repeat(64));
|
|
try {
|
|
const f = await seedInterruptionFixture(db, { selected: 1, history: 1, unrelated: 0, payloadBytes: 0 });
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
|
const secret = `private-workspace-value-${randomUUID()}`;
|
|
const error = `Could not prepare the project folder: ${secret}.`;
|
|
await db.update(heartbeatRuns).set({ errorCode: "workspace_validation_failed", error }).where(eq(heartbeatRuns.id, run!.id));
|
|
await createRunSecretRedactionRegistry(db).register(f.companyId, run!.id, secret);
|
|
const list = await taskInterruptionPresentations(db, f.companyId, f.input);
|
|
const detail = await taskInterruptionPresentation(db, f.input[0]!);
|
|
for (const presentation of [list.get(f.input[0]!.id), detail]) {
|
|
expect(presentation?.workspaceFailure?.message).toContain("Could not prepare the project folder");
|
|
expect(presentation?.workspaceFailure?.message).toContain("REDACTED");
|
|
expect(JSON.stringify(presentation)).not.toContain(secret);
|
|
}
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "b".repeat(64));
|
|
expect(await taskInterruptionPresentation(db, f.input[0]!)).not.toHaveProperty("workspaceFailure");
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)))[0]?.error).toBe(error);
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it.each(["valid", "numeric_user", "numeric_audit"] as const)("retains bounded typed Stop evidence in the real batched projection (%s)", async variant => {
|
|
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Stopped task", issuePrefix: `S${companyId.slice(0, 7)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Stopped agent", adapterType: "paperclip_runner" });
|
|
const [issue] = await db.insert(issues).values({ companyId, title: "Keep this task in progress",
|
|
status: "in_progress", assigneeAgentId: agentId }).returning();
|
|
const nativeCancellation = { schema: "paperclip.native-cancellation.v1", companyId, runId, issueId: issue.id,
|
|
scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
|
|
intentAuditId: variant === "numeric_audit" ? 123 : randomUUID(), acknowledgementAuditId: randomUUID() };
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issue.id,
|
|
scopeKind: "issue", status: "cancelled", runtimeMode: "native", driverKind: "cursor_cloud_api",
|
|
resultJson: { cancelledByActorType: "user", cancelledByUserId: variant === "numeric_user" ? 123 : "board", nativeCancellation,
|
|
executionCancellation: { state: "requested" }, unrelatedPayload: "x".repeat(128_000) } });
|
|
const result = await taskInterruptionPresentations(db, companyId, [issue]);
|
|
expect(result.get(issue.id)).toMatchObject(variant === "valid"
|
|
? { state: "resolved", canRetry: false } : { state: "failed", canRetry: true });
|
|
const query = await captureInterruptionQuery(db, taskInterruptionPresentations, companyId, [issue]);
|
|
const [projected] = await db.$client.unsafe(query.sql, query.params as never[]);
|
|
expect(projected.jsonb_build_object.nativeCancellation).toEqual({ ...nativeCancellation,
|
|
intentAuditId: variant === "numeric_audit" ? null : nativeCancellation.intentAuditId });
|
|
expect(projected.jsonb_build_object.cancelledByUserId).toBe(variant === "numeric_user" ? null : "board");
|
|
expect(projected).toMatchObject({ company_id: companyId, native_issue_id: issue.id, runtime_mode: "native" });
|
|
expect(projected.jsonb_build_object).not.toHaveProperty("unrelatedPayload");
|
|
expect(JSON.stringify(projected.jsonb_build_object).length).toBeLessThan(4000);
|
|
});
|
|
|
|
it("redacts registered run secrets from both task detail and list provider explanations", async () => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "a".repeat(64));
|
|
try {
|
|
const f = await seedInterruptionFixture(db, { selected: 1, history: 1, unrelated: 0, payloadBytes: 0 });
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
|
const secret = `private-runtime-value-${randomUUID()}`;
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup",
|
|
category: "unknown", message: `Provider rejected runtime value ${secret}.`, retryability: "unknown" };
|
|
await db.update(heartbeatRuns).set({ resultJson: { providerFailure } }).where(eq(heartbeatRuns.id, run!.id));
|
|
await createRunSecretRedactionRegistry(db).register(f.companyId, run!.id, secret);
|
|
const list = await taskInterruptionPresentations(db, f.companyId, f.input);
|
|
const detail = await taskInterruptionPresentation(db, f.input[0]!);
|
|
for (const presentation of [list.get(f.input[0]!.id), detail]) {
|
|
expect(presentation?.providerFailure?.message).toContain("Provider rejected runtime value");
|
|
expect(JSON.stringify(presentation)).not.toContain(secret);
|
|
}
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)))[0]?.resultJson).toEqual({ providerFailure });
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("redacts a scheduled retry's predecessor provider explanation with the predecessor's registry", async () => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "a".repeat(64));
|
|
try {
|
|
const f = await seedInterruptionFixture(db, { selected: 1, history: 1, unrelated: 0, payloadBytes: 0 });
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
|
const predecessorId = randomUUID();
|
|
const secret = `private-predecessor-value-${randomUUID()}`;
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "daytona", phase: "startup",
|
|
category: "overloaded", message: `Sandbox refused lease for ${secret}.`, httpStatus: 502, retryability: "transient" };
|
|
await db.insert(heartbeatRuns).values({ id: predecessorId, companyId: f.companyId, agentId: run!.agentId,
|
|
status: "failed", errorCode: "environment_provider_unavailable", resultJson: { providerFailure },
|
|
createdAt: new Date(run!.createdAt.getTime() - 60_000) });
|
|
await db.update(heartbeatRuns).set({ status: "scheduled_retry", retryOfRunId: predecessorId, resultJson: {},
|
|
scheduledRetryAt: new Date(Date.now() + 30_000), scheduledRetryAttempt: 1 }).where(eq(heartbeatRuns.id, run!.id));
|
|
await createRunSecretRedactionRegistry(db).register(f.companyId, predecessorId, secret);
|
|
const list = await taskInterruptionPresentations(db, f.companyId, f.input);
|
|
const detail = await taskInterruptionPresentation(db, f.input[0]!);
|
|
for (const presentation of [list.get(f.input[0]!.id), detail]) {
|
|
expect(presentation?.observedRunId).toBe(run!.id);
|
|
expect(presentation?.providerFailure).toMatchObject({ httpStatus: 502 });
|
|
expect(presentation?.providerFailure?.message).toContain("Sandbox refused lease");
|
|
expect(presentation?.providerFailure?.message).toContain("REDACTED");
|
|
expect(JSON.stringify(presentation)).not.toContain(secret);
|
|
}
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it.each(["decryption failure", "expanded message"])("keeps task reads available after provider display %s", async scenario => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "a".repeat(64));
|
|
try {
|
|
const f = await seedInterruptionFixture(db, { selected: 1, history: 1, unrelated: 0, payloadBytes: 0 });
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
|
const secret = "pw-short";
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup",
|
|
category: "unknown", message: secret + "🚀".repeat(4000 - secret.length), retryability: "unknown" };
|
|
await db.update(heartbeatRuns).set({ resultJson: { providerFailure } }).where(eq(heartbeatRuns.id, run!.id));
|
|
await createRunSecretRedactionRegistry(db).register(f.companyId, run!.id, secret);
|
|
if (scenario === "decryption failure") vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "b".repeat(64));
|
|
const presentation = await taskInterruptionPresentation(db, f.input[0]!);
|
|
expect(presentation).toMatchObject({ state: "failed", canRetry: true });
|
|
if (scenario === "decryption failure") expect(presentation).not.toHaveProperty("providerFailure");
|
|
else {
|
|
expect(presentation?.providerFailure?.message).toContain("REDACTED");
|
|
expect([...presentation!.providerFailure!.message]).toHaveLength(4000);
|
|
}
|
|
expect(JSON.stringify(presentation)).not.toContain(secret);
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("does not inspect unrelated company history for a small issue page", async () => {
|
|
const f = await seedInterruptionFixture(db, { selected: 2, history: 3, unrelated: 1500, payloadBytes: 4096 });
|
|
const query = await captureInterruptionQuery(db, taskInterruptionPresentations, f.companyId, f.input);
|
|
const [explain] = await db.$client.unsafe(`explain (analyze, buffers, format json) ${query.sql}`, query.params as never[]);
|
|
const inspected: number[] = [];
|
|
const visit = (node: Record<string, unknown>) => {
|
|
if (node["Relation Name"] === "heartbeat_runs") inspected.push(
|
|
(Number(node["Actual Rows"] ?? 0) + Number(node["Rows Removed by Filter"] ?? 0)) * Number(node["Actual Loops"] ?? 1));
|
|
for (const child of node.Plans as Record<string, unknown>[] ?? []) visit(child);
|
|
};
|
|
visit(explain["QUERY PLAN"][0].Plan);
|
|
expect(inspected.length).toBeGreaterThan(0);
|
|
expect(Math.max(...inspected)).toBeLessThan(30);
|
|
const presentations = await taskInterruptionPresentations(db, f.companyId, f.input);
|
|
for (const issue of f.input) expect(presentations.get(issue.id)).toMatchObject({ state: "failed", canRetry: true });
|
|
for (const issue of f.input) expect(captureTaskInterruptionFailure).toHaveBeenCalledWith(expect.objectContaining({
|
|
companyId: f.companyId, taskId: issue.id, runId: presentations.get(issue.id)!.observedRunId,
|
|
runStatus: "failed", agentAdapter: "process",
|
|
}));
|
|
});
|
|
|
|
it("preserves native, first-class and text legacy precedence, ties, and tenant scope", async () => {
|
|
const companyId = randomUUID(), otherId = randomUUID(), agentId = randomUUID();
|
|
await db.insert(companies).values([companyId, otherId].map(id => ({ id, name: "Selectors", issuePrefix: `S${id.slice(0, 7)}` })));
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Selector agent", adapterType: "process" });
|
|
const input = await db.insert(issues).values(Array.from({ length: 10 }, (_, n) => ({ companyId, title: `Selector ${n}`, status: "in_progress", assigneeAgentId: agentId }))).returning();
|
|
const base = { companyId, agentId, status: "failed", createdAt: new Date("2026-10-27T00:00:00Z") };
|
|
const winnerIds = Array.from({ length: 7 }, () => randomUUID());
|
|
await db.$client.unsafe("alter table heartbeat_runs disable trigger heartbeat_runs_set_scope_kind");
|
|
await db.insert(heartbeatRuns).values([
|
|
{ ...base, id: winnerIds[0], nativeIssueId: input[0]!.id, issueId: input[1]!.id, scopeKind: "issue", contextSnapshot: { issueId: input[2]!.id } },
|
|
{ ...base, id: winnerIds[1], issueId: input[1]!.id, scopeKind: "issue", contextSnapshot: { issueId: input[2]!.id } },
|
|
{ ...base, id: winnerIds[2], contextSnapshot: { issueId: input[2]!.id } },
|
|
{ ...base, nativeIssueId: randomUUID(), contextSnapshot: { issueId: input[3]!.id } },
|
|
{ ...base, nativeIssueId: null, issueId: input[9]!.id, scopeKind: "issue", contextSnapshot: { issueId: input[3]!.id } },
|
|
{ ...base, contextSnapshot: { issueId: input[3]!.id.toUpperCase() } },
|
|
...[null, "", [], 5, { issueId: "" }, { issueId: {} }, { issueId: [] }].map(contextSnapshot => ({ ...base, contextSnapshot: contextSnapshot as never })),
|
|
{ ...base, id: winnerIds[3], nativeIssueId: input[4]!.id, contextSnapshot: null },
|
|
{ ...base, id: winnerIds[4], issueId: input[5]!.id, scopeKind: "issue", contextSnapshot: [input[3]!.id] as never },
|
|
{ ...base, id: "00000000-0000-0000-0000-000000000001", contextSnapshot: { issueId: input[6]!.id } },
|
|
{ ...base, id: "ffffffff-ffff-ffff-ffff-ffffffffffff", nativeIssueId: input[6]!.id, contextSnapshot: { issueId: "" } },
|
|
{ ...base, companyId: otherId, nativeIssueId: input[3]!.id, createdAt: new Date("2026-10-28T00:00:00Z") },
|
|
]);
|
|
await db.$client.unsafe("alter table heartbeat_runs enable trigger heartbeat_runs_set_scope_kind");
|
|
const result = await taskInterruptionPresentations(db, companyId, [...input, input[0]!, { ...input[0]!, companyId: otherId }]);
|
|
for (let n = 0; n < 3; n++) expect(result.get(input[n]!.id)?.observedRunId).toBe(winnerIds[n]);
|
|
expect(result.get(input[3]!.id)).toBeNull();
|
|
expect(result.get(input[4]!.id)?.observedRunId).toBe(winnerIds[3]);
|
|
expect(result.get(input[5]!.id)?.observedRunId).toBe(winnerIds[4]);
|
|
expect(result.get(input[6]!.id)?.observedRunId).toBe("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
|
expect(result.get(input[7]!.id)).toBeNull();
|
|
expect(result.get(input[8]!.id)).toBeNull();
|
|
const upper = { ...input[4]!, id: input[4]!.id.toUpperCase() };
|
|
expect((await taskInterruptionPresentations(db, companyId, [upper])).get(upper.id)).toBeNull();
|
|
expect(await taskInterruptionPresentations(db, companyId, [])).toEqual(new Map());
|
|
});
|
|
|
|
it("reports the saved-message failure state without reporting work, retries or user actions", async () => {
|
|
const companyId = randomUUID(), agentId = randomUUID(), pausedAgentId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Failure states", issuePrefix: `F${companyId.slice(0, 7)}` });
|
|
await db.insert(agents).values([
|
|
{ id: agentId, companyId, name: "Worker", adapterType: "process" },
|
|
{ id: pausedAgentId, companyId, name: "Paused worker", adapterType: "process", status: "paused" },
|
|
]);
|
|
const input = await db.insert(issues).values(Array.from({ length: 9 }, (_, n) => ({
|
|
companyId, title: `State ${n}`, status: n === 5 ? "done" : "in_progress",
|
|
assigneeAgentId: n === 8 ? pausedAgentId : agentId,
|
|
}))).returning();
|
|
const runs = await db.insert(heartbeatRuns).values(input.map((issue, n) => ({
|
|
companyId, agentId: issue.assigneeAgentId!, issueId: issue.id,
|
|
status: n === 1 || n === 4 ? "succeeded" : n === 2 ? "running" : n === 7 ? "cancelled" : "failed",
|
|
errorCode: n === 6 ? "native_provider_approval_required" : null,
|
|
scheduledRetryAt: n === 3 ? new Date(Date.now() + 60_000) : null,
|
|
}))).returning();
|
|
const [wait] = await db.insert(agentWakeupRequests).values({
|
|
companyId, agentId, source: "on_demand", status: "deferred_issue_execution",
|
|
requestedByActorType: "user", payload: { issueId: input[1]!.id }, requestedAt: new Date(Date.now() - 20_000),
|
|
}).returning();
|
|
captureTaskInterruptionFailure.mockClear();
|
|
const result = await taskInterruptionPresentations(db, companyId, [...input, { ...input[0]!, companyId: randomUUID() }]);
|
|
expect(captureTaskInterruptionFailure).toHaveBeenCalledTimes(3);
|
|
for (const n of [0, 1, 7]) {
|
|
expect(result.get(input[n]!.id)).toMatchObject({ responsibility: "system", state: "failed" });
|
|
expect(captureTaskInterruptionFailure).toHaveBeenCalledWith({
|
|
companyId, agentId, taskId: input[n]!.id, runId: runs[n]!.id,
|
|
runStatus: runs[n]!.status, agentAdapter: "process", intentId: n === 1 ? wait!.id : null,
|
|
});
|
|
}
|
|
expect(result.get(input[2]!.id)?.state).toBe("working");
|
|
expect(result.get(input[3]!.id)?.state).toBe("retry_scheduled");
|
|
for (const n of [4, 5]) expect(result.get(input[n]!.id)?.state).toBe("resolved");
|
|
for (const n of [6, 8]) expect(result.get(input[n]!.id)?.responsibility).toBe("user");
|
|
});
|
|
|
|
it("keeps the failed run's adapter after the task is reassigned", async () => {
|
|
const f = await seedInterruptionFixture(db, { selected: 1, history: 1, unrelated: 0, payloadBytes: 0 });
|
|
const [nextAgent] = await db.insert(agents).values({
|
|
companyId: f.companyId, name: "Next worker", adapterType: "codex_local",
|
|
}).returning();
|
|
const [reassigned] = await db.update(issues).set({ assigneeAgentId: nextAgent!.id })
|
|
.where(eq(issues.id, f.input[0]!.id)).returning();
|
|
captureTaskInterruptionFailure.mockClear();
|
|
const result = await taskInterruptionPresentations(db, f.companyId, [reassigned!]);
|
|
expect(result.get(reassigned!.id)?.state).toBe("failed");
|
|
expect(captureTaskInterruptionFailure).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
|
taskId: reassigned!.id, agentAdapter: "process",
|
|
}));
|
|
});
|
|
// N-03: ONB-1 on staging waited 16 minutes on an overdue workspace_busy
|
|
// retry. Nothing failed, yet the banner offered a Retry that cannot help.
|
|
it.each(["workspace_busy", "ai_connection_busy", "ai_connection_pool_wait", "runner_revision_wait"])(
|
|
"does not present an overdue %s wait as a failed attempt", async reason => {
|
|
const companyId = randomUUID(), agentId = randomUUID(), parentRunId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Waiting task", issuePrefix: `W${companyId.slice(0, 7)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Waiting agent", adapterType: "process" });
|
|
const [issue] = await db.insert(issues).values({ companyId, title: "Paperclip onboarding",
|
|
status: "in_progress", assigneeAgentId: agentId }).returning();
|
|
const now = Date.now();
|
|
await db.insert(heartbeatRuns).values({ id: parentRunId, companyId, agentId, issueId: issue!.id, scopeKind: "issue",
|
|
status: "cancelled", errorCode: reason, createdAt: new Date(now - 10 * 60_000), finishedAt: new Date(now - 9 * 60_000) });
|
|
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId: issue!.id, scopeKind: "issue",
|
|
status: "scheduled_retry", retryOfRunId: parentRunId, scheduledRetryReason: reason, scheduledRetryAttempt: 2,
|
|
scheduledRetryAt: new Date(now - 5 * 60_000), createdAt: new Date(now - 9 * 60_000) });
|
|
captureTaskInterruptionFailure.mockClear();
|
|
const presentation = (await taskInterruptionPresentations(db, companyId, [issue!])).get(issue!.id);
|
|
expect(presentation).toMatchObject({ responsibility: "system", state: "working", canRetry: false });
|
|
expect(captureTaskInterruptionFailure).not.toHaveBeenCalled();
|
|
});
|
|
});
|