diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 5eb3248d65e..4c4601dabc8 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -959,6 +959,14 @@ workspace finalization, and environment cleanup to settle. Model incompatibility usage-limit exhaustion, unknown failures, and unbound diagnostic text do not qualify as capacity failures. +A definite `session.open` rejection whose runner classification says the +provider cannot run the selected model (`requested_model_unsupported`, +`grok_model_mismatch`) is a model rejection, not a startup interruption. It +stops on its first attempt as `native_provider_model_rejected`, with no +same-run bootstrap retry, and leaves an active board recovery action that asks +for a supported model. Other `session.open` rejections keep the bounded +startup retry. An indeterminate `session.open` outcome stays unclassified. + ### Workspace scan failures before provider startup Workspace source preparation keeps its dedicated advisory-lock connection active diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index d0bb518a579..39a403fe553 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -17979,6 +17979,15 @@ it("classifies only a definite session.open outcome as a bounded pre-session fai .toBe("native_session_interrupted"); expect(nativeSessionFailureSourceCode(new Error(`PRP command turn.start failed: ${detail}`))) .toBe("native_session_interrupted"); + // A definite model rejection needs a model choice; an indeterminate one stays unknown. + const modelDetail = detail.replace("classification=session_ensure_failed", "classification=grok_model_mismatch"); + const modelCode = nativeSessionFailureSourceCode(new Error(`PRP command session.open failed: ${modelDetail}`)); + expect(modelCode).toBe("native_provider_model_rejected"); + expect(nativeSessionFailureDisposition(1, new Date(), modelCode)).toEqual({ + phase: "terminal_failure", failureCode: "native_provider_model_rejected", nextAttemptAt: null, + }); + expect(nativeSessionFailureSourceCode(new Error(`PRP command session.open indeterminate: ${modelDetail}`))) + .toBe("native_session_interrupted"); }); describe("HTTP Stop during remote runner staging", () => { it("does not dispatch after Stop and bounded bootstrap cleanup", async () => { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index f55117d6b26..da9b82fbb41 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -7305,6 +7305,12 @@ export function nativeSessionFailureSourceCode( // before any prompt, so provider work cannot have started. An indeterminate // outcome stays generic. if (/\bPRP command session\.open (?:failed|rejected): /.test(message)) { + // The sidecar's closed classification says the provider cannot run the + // selected model. A same-run bootstrap retry would open the same session + // and fail the same way, so this needs a model choice, not a retry. + if (/classification=(?:requested_model_unsupported|grok_model_mismatch)\)/.test(message)) { + return "native_provider_model_rejected"; + } return "provider_session_open_rejected"; } return "native_session_interrupted"; @@ -7313,6 +7319,9 @@ export function nativeSessionFailureSourceCode( const NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION = "The current runner cannot restore this task's saved provider session, so retrying this run cannot succeed. Retry the task to continue in a fresh provider session with the same workspace. The earlier provider conversation is not carried over."; +const NATIVE_STARTUP_MODEL_REJECTED_ACTION = + "The provider could not start the selected model before any provider work began. Choose a supported model for this agent, then retry the task. Automatic retries are stopped."; + const NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE = "Send a new message to continue after Paperclip verifies that the previous provider and its tools have stopped. If cleanup cannot be verified, inspect the run and its environment. Clearing a task session does not resolve this quarantine. Automatic retries are stopped."; @@ -10746,6 +10755,8 @@ async function executePaperclipNativeSessionWithinScope( ? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped." : sourceFailureCode === "native_provider_terminal_failed" ? "The provider session is permanently unusable. Verify stopped execution, completed actions, and task context before starting a linked continuation." + : sourceFailureCode === "native_provider_model_rejected" && !recoveryEvidence.providerSessionEstablished + ? NATIVE_STARTUP_MODEL_REJECTED_ACTION : sourceFailureCode === NATIVE_PROVIDER_STATE_INCOMPATIBLE ? NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION : recoveryEvidence.recoveryMode === "ambiguous_state" @@ -10997,6 +11008,8 @@ async function executePaperclipNativeSessionWithinScope( ? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped." : sourceFailureCode === "native_provider_terminal_failed" ? "Verify that the failed provider stopped and reconcile its action outcomes. A linked continuation can proceed only after these checks succeed." + : sourceFailureCode === "native_provider_model_rejected" && !recoveryEvidence.providerSessionEstablished + ? NATIVE_STARTUP_MODEL_REJECTED_ACTION : sourceFailureCode === NATIVE_PROVIDER_STATE_INCOMPATIBLE ? NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION : recoveryEvidence.recoveryMode === "ambiguous_state" diff --git a/server/src/services/native-runtime/native-startup-retry.integration.test.ts b/server/src/services/native-runtime/native-startup-retry.integration.test.ts index 6892000df69..df2eece61f7 100644 --- a/server/src/services/native-runtime/native-startup-retry.integration.test.ts +++ b/server/src/services/native-runtime/native-startup-retry.integration.test.ts @@ -38,6 +38,7 @@ import { currentNativeControllerIdentity } from "./native-restart-recovery.js"; import { loadNativeRunnerCheckpoint } from "./native-runner-checkpoint.js"; import { environmentRunnerIngressErrorData, JsonRpcCallError, PluginEnvironmentRunnerIngressError } from "@paperclipai/plugin-sdk"; import { accountRunCost } from "../run-cost-accounting.js"; +import { settleUnrecoverableExecutions } from "../execution-recovery-resolution.js"; type SessionOpenStatus = "failed" | "rejected" | "indeterminate"; @@ -45,6 +46,7 @@ type SessionOpenStatus = "failed" | "rejected" | "indeterminate"; function sessionOpenFailure(options: { status?: SessionOpenStatus; providerFailure?: Record; + classification?: string; } = {}) { const status = options.status ?? "failed"; const result = { @@ -52,7 +54,7 @@ function sessionOpenFailure(options: { code: "command_execution_failed", message: options.providerFailure ? "Provider request failed" - : "failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed)", + : `failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=${options.classification ?? "session_ensure_failed"})`, ...(options.providerFailure ? { providerFailure: options.providerFailure } : {}), }, status, @@ -476,6 +478,53 @@ describe("native provider startup retry", () => { expect(state.issue.status).toBe("in_progress"); }); + it.each(["grok_model_mismatch", "requested_model_unsupported"])( + "stops at a session.open %s rejection with a choose-model action instead of a doomed retry", + async (classification) => { + const seeded = await seedRun(await createAgent()); + await failStartup(seeded, sessionOpenFailure({ classification })); + + const state = await read(seeded); + // No automatic same-run bootstrap retry: the selected model cannot start. + expect(state.coordinator).toMatchObject({ + phase: "terminal_failure", + recoveryState: "blocked", + failureCode: "native_provider_model_rejected", + attempt: 1, + nextAttemptAt: null, + failureDetail: expect.objectContaining({ + originalFailureCode: "native_provider_model_rejected", + providerSessionEstablished: false, + providerEventsExist: false, + checkpointExists: false, + }), + }); + expect(String(state.coordinator.failureDetail?.nextAction)).toContain("Choose a supported model"); + expect(state.run).toMatchObject({ status: "failed", errorCode: "native_provider_model_rejected" }); + expect(state.issue.status).toBe("blocked"); + expect(state.issue.executionRunId).toBeNull(); + // A named, durable board action owns the next step. + expect(state.action).toMatchObject({ + status: "active", + ownerType: "board", + cause: "native_provider_model_rejected", + returnOwnerAgentId: seeded.agentId, + wakePolicy: null, + }); + expect(state.action!.nextAction).toContain("Choose a supported model"); + // The no-replay settler must not fold this owned action into a silent hold. + await settleUnrecoverableExecutions(db); + expect((await read(seeded)).action).toMatchObject({ id: state.action!.id, status: "active" }); + expect(await claimNativeSessionResumptions({ + db, + runnerInstanceId: "startup-retry-test", + runIds: [seeded.runId], + now: new Date(Date.now() + 60 * 60_000), + deferIneligible: false, + })).toEqual([]); + }, + ); + it("blocks with a clear reason once the bounded startup budget is exhausted", async () => { const seeded = await seedRun(await createAgent()); await failStartup(seeded, sessionOpenFailure());