mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix: keep quarantined chat retries disabled after continuation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3592b04c2b
commit
db6f775dff
7 files changed
+25
-5
No files matched your search
@@ -910,7 +910,8 @@ The fixture then releases the read-only brief wait and sends a new chat message
|
||||
that records the known saved-plan and interrupted-command outcomes. The server
|
||||
must verify that the recorded worker and provider process groups stopped before
|
||||
admitting exactly one successful fresh session. The answer must contain the
|
||||
reference supplied only after the crash, and the saved plan must remain unchanged.
|
||||
reference supplied only after the crash, and the saved plan must remain unchanged. The old quarantined run must not regain
|
||||
a misleading Try again control after the fresh turn succeeds.
|
||||
This qualifies **explicit conversation continuation after local worker loss**.
|
||||
It does not qualify replay of uncertain actions, automatic recovery, remote worker
|
||||
loss, or exact-session resumption. It budgets two provider runs. Unexpected
|
||||
@@ -939,6 +940,6 @@ it does not certify a native option in the wizard, which is not offered yet.
|
||||
|
||||
Current proof and remaining decisions are recorded in
|
||||
[the 21 September qualification report](QUALIFICATION-2026-09-21.md). In particular,
|
||||
the original worker-loss attempts quarantined both providers. The version 8 crash
|
||||
the original worker-loss attempts quarantined both providers. The version 9 crash
|
||||
eval requires a usable fresh conversation after verified cleanup. A passing
|
||||
quarantine guard alone is not a recovered workflow.
|
||||
@@ -975,7 +975,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id))
|
||||
.map(profile => productionStoryProfile(defaultPermissionProfile(profile))),
|
||||
environments: [localEnvironment], tasks: chatQualificationTasks, expectedMatrixSize: 6,
|
||||
definitionMetadata: { version: 8, permissions: "production-defaults", instructions: "production", crashBoundary: "verified-native-worker-pid-at-file-wait", recovery: "new-user-message-after-verified-cleanup", answerGrading: "exact-grounded-propositions-plus-separate-semantic-review", scheduling: "explicit-only" },
|
||||
definitionMetadata: { version: 9, permissions: "production-defaults", instructions: "production", crashBoundary: "verified-native-worker-pid-at-file-wait", recovery: "new-user-message-after-verified-cleanup", answerGrading: "exact-grounded-propositions-plus-separate-semantic-review", scheduling: "explicit-only" },
|
||||
},
|
||||
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
|
||||
{
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function runWorkerCrash(context: Context) {
|
||||
await input.page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
await expect(input.page.getByRole("status", { name: "Task recovery" }).getByRole("link", { name: "Inspect run" })).toBeVisible();
|
||||
await expect(input.page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
||||
await expect(input.page.getByRole("button", { name: /^(Retry|Try again)$/ })).toHaveCount(0);
|
||||
const refused = await input.api.request.post(`/api/agents/${input.fixtures.agent.id}/wakeup`, {
|
||||
data: { failedRunId: failed.id, reason: "retry_failed_run" },
|
||||
});
|
||||
@@ -195,6 +195,8 @@ export async function runWorkerCrash(context: Context) {
|
||||
planAfter: await input.api.get<Row>(`/api/issues/${context.issue().id}/documents/plan`) };
|
||||
await input.evidence("chat-worker-recovery.json", e);
|
||||
assertCrashRecovered(e);
|
||||
// A fresh success must not re-enable Retry on the quarantined historical run.
|
||||
await expect(input.page.getByTestId("task-chat-run-failed-try-again")).toHaveCount(0);
|
||||
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
|
||||
} finally {
|
||||
await writeFile(wait.gate, reference);
|
||||
|
||||
@@ -1170,6 +1170,20 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("failed-bootstrap");
|
||||
});
|
||||
|
||||
it.each([false, true])("never offers the old quarantined run as Try again after continuation: %s", continued => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress"
|
||||
onRetryFailedRun={vi.fn()} linkedRuns={[{
|
||||
runId: "quarantined", runtimeMode: "native", status: "failed", errorCode: "native_session_cleanup_quarantined",
|
||||
agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:02.000Z",
|
||||
...(continued ? { execution: { phase: "completed" as const, label: "Continued in another run", cause: "native_session_cleanup_quarantined",
|
||||
lastConfirmedActivityAt: null, retryAt: null, attempt: 2, maxAttempts: 3, recoveryOwner: null, nextAction: null,
|
||||
permittedActions: ["inspect_run" as const], predecessorRunId: null, successorRunId: "fresh-run" } } : {}),
|
||||
}]} />);
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("explains a native provider usage limit without exposing its error code", async () => {
|
||||
const onRetryFailedRun = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1614,6 +1614,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
tone: source.status === "cancelled" ? "neutral" : "error",
|
||||
label,
|
||||
detail,
|
||||
retryable: code !== "native_session_cleanup_quarantined",
|
||||
collapsible: true,
|
||||
runId: source.id,
|
||||
createdAtIso: finishedAt
|
||||
|
||||
@@ -172,7 +172,7 @@ function renderItem(
|
||||
<TaskChatMarker
|
||||
item={item}
|
||||
onTryAgain={
|
||||
item.id === retryableMarkerId
|
||||
item.id === retryableMarkerId && item.retryable !== false
|
||||
? item.runId && onRetryFailedRun
|
||||
? () => onRetryFailedRun(item.runId!)
|
||||
: onTryAgainNoLiveExecutionPath
|
||||
|
||||
@@ -246,6 +246,8 @@ export interface TaskChatMarkerItem {
|
||||
variant: "session_start" | "interrupted" | "turn_boundary";
|
||||
label: string;
|
||||
detail?: string;
|
||||
/** False when the recorded run cannot be retried, even after the chat continues. */
|
||||
retryable?: boolean;
|
||||
/** Renders the marker as a quiet disclosure row with detail beneath it. */
|
||||
collapsible?: boolean;
|
||||
/** Expected cancellation is neutral; unexpected failures remain destructive. */
|
||||
|
||||
Reference in new issue
Block a user