diff --git a/src/lib/actions/sandbox/process-recovery.test.ts b/src/lib/actions/sandbox/process-recovery.test.ts index b814b0b0b9..4a757c15db 100644 --- a/src/lib/actions/sandbox/process-recovery.test.ts +++ b/src/lib/actions/sandbox/process-recovery.test.ts @@ -109,6 +109,43 @@ describe("recreated sandbox OpenShell readiness", () => { expect(sleeps).toEqual([3]); }); + it("rides out a transient Error phase past the old 30s budget by default (#7227)", () => { + // No timeoutSeconds option and no env override: the default recovery budget + // must be large enough (120s, aligned with connect's readiness wait) to keep + // retrying a cold-start phase:Error settling window that exceeds the old + // 30s / 11-attempt budget. The 12th probe (past the old 11-attempt cap) must + // still be reached, so the primary dashboard/API forward is not abandoned. + delete process.env.NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS; + const errorPhase = { + status: 1, + output: OPENSHELL_TRANSIENT_ERROR_PHASE_STDERR.trim(), + stdout: "", + stderr: OPENSHELL_TRANSIENT_ERROR_PHASE_STDERR, + }; + const captureOpenshellImpl = vi.fn(); + for (let attempt = 0; attempt < 11; attempt += 1) { + captureOpenshellImpl.mockReturnValueOnce(errorPhase); + } + captureOpenshellImpl.mockReturnValueOnce({ + status: 0, + output: "", + stdout: "", + stderr: "", + }); + + expect( + waitForRecreatedSandboxOpenShellReady("recreated-box", { + beforeProbe: () => true, + captureOpenshellImpl, + intervalSeconds: 3, + sleepImpl: () => {}, + // no timeoutSeconds -> exercise the default budget; the old 30s default + // capped at 11 attempts and would have given up before the 12th probe. + }), + ).toBe(true); + expect(captureOpenshellImpl).toHaveBeenCalledTimes(12); + }); + it("retries the exact supervisor reconnect states exposed during direct recreation", () => { const reconnecting = [ OPENSHELL_SUPERVISOR_NOT_CONNECTED_STDERR, diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index c3f0d58372..4c4f620cf0 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -700,6 +700,16 @@ function recreatedSandboxOpenShellReadinessFailureDetail( return openshellError ? `${detail} Last OpenShell readiness error: ${openshellError}` : detail; } +// Default seconds to wait for OpenShell to re-register a recreated sandbox as +// Ready before giving up and surfacing the manual-recover hint. Aligned with +// `connect`'s readiness budget (`waitForSandboxReadyOrExit` defaults to 120s): +// both prove the same post-recreate sandbox readiness, but this path used to +// give up 4x sooner (30s), so a cold-start `phase: Error` settling window that +// exceeded 30s but was within `connect`'s 120s left the primary dashboard/API +// forward unstarted — exactly why `connect --probe-only` recovers what `start` +// abandons (#7227). Env-tunable via NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS. +const GATEWAY_RECOVERY_WAIT_DEFAULT_SECONDS = 120; + /** * Wait until OpenShell has re-registered a directly recreated sandbox as * ready. This probe deliberately has no direct-Docker or SSH fallback: it is @@ -718,7 +728,10 @@ function waitForRecreatedSandboxOpenShellReadyResult( Number.isFinite(options.timeoutSeconds) && options.timeoutSeconds >= 0 ? options.timeoutSeconds - : readNonNegativeNumberEnv("NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS", 30); + : readNonNegativeNumberEnv( + "NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS", + GATEWAY_RECOVERY_WAIT_DEFAULT_SECONDS, + ); const intervalSeconds = readNonNegativeNumberEnv( "NEMOCLAW_GATEWAY_RECOVERY_POLL_INTERVAL_SECONDS", options.intervalSeconds ?? 3, @@ -914,7 +927,7 @@ export function waitForRecoveredSandboxGateway( Number.isFinite(options.timeoutSeconds) && options.timeoutSeconds >= 0 ? options.timeoutSeconds - : 30; + : GATEWAY_RECOVERY_WAIT_DEFAULT_SECONDS; const timeoutSeconds = readNonNegativeNumberEnv( "NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS", requestedTimeoutSeconds,