diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index 17e4f84e96e..e966b001178 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -372,7 +372,6 @@ export function createSandboxOnboardFlowPhase< context: mergeSandboxCreatedContext(context, { session: sandboxStateResult.session, sandboxName: sandboxStateResult.sandboxName, - recreateJournalHandoff: Boolean(options.recreateJournalTargetIntentFingerprint), webSearchConfig: sandboxStateResult.webSearchConfig, webSearchConfigChanged: sandboxStateResult.webSearchConfigChanged, hermesToolGateways: sandboxStateResult.hermesToolGateways, diff --git a/src/lib/onboard/machine/final-flow-phases.ts b/src/lib/onboard/machine/final-flow-phases.ts index 80efe6fd3e4..2c50ebad123 100644 --- a/src/lib/onboard/machine/final-flow-phases.ts +++ b/src/lib/onboard/machine/final-flow-phases.ts @@ -133,7 +133,6 @@ export function createFinalOnboardFlowPhases< ? options.finalization.webSearchProvider(context.webSearchConfig) : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", - recreateJournalHandoff: context.recreateJournalHandoff, deps: finalizationDeps, }); return { result: finalizationResult.stateResult }; @@ -158,7 +157,6 @@ export function createFinalOnboardFlowPhases< ? options.finalization.webSearchProvider(context.webSearchConfig) : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", - recreateJournalHandoff: context.recreateJournalHandoff, deps: finalizationDeps, }); return { result: postVerifyResult.stateResult }; diff --git a/src/lib/onboard/machine/flow-context.ts b/src/lib/onboard/machine/flow-context.ts index 73df46ed8ae..7b2e6f6ffa5 100644 --- a/src/lib/onboard/machine/flow-context.ts +++ b/src/lib/onboard/machine/flow-context.ts @@ -10,7 +10,6 @@ import type { OnboardStateHandlerResult } from "./runner"; export interface OnboardFlowContext { resume: boolean; fresh: boolean; - recreateJournalHandoff?: boolean; session: Session | null; agent: Agent; recordedSandboxName: string | null; @@ -92,7 +91,6 @@ export interface ProviderModelSelectedContextUpdate { export interface SandboxCreatedContextUpdate { session: Session | null; sandboxName: string; - recreateJournalHandoff?: boolean; webSearchConfig: WebSearchConfig | null; webSearchConfigChanged: boolean; hermesToolGateways: string[]; diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts index cbb495ec9a1..4118ab23777 100644 --- a/src/lib/onboard/machine/handlers/finalization.test.ts +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -541,35 +541,44 @@ describe("finalization handlers", () => { expect(calls.reportReadiness).toHaveBeenCalledWith(false); }); - it("settles ordinary OpenClaw pairing after recovery and before verification (#9844)", async () => { - const { deps, calls } = createDeps(); + it("settles ordinary OpenClaw pairing after recovery and before verification (#10479)", async () => { + let releasePairing!: () => void; + const pairingPending = new Promise((resolve) => { + releasePairing = resolve; + }); + const events: string[] = []; + const settleOrdinaryPairing = vi.fn(async () => { + events.push("pairing-started"); + await pairingPending; + events.push("pairing-settled"); + return { kind: "settled" as const }; + }); + const verifyDeployment = vi.fn(async () => { + events.push("verify"); + return { ok: true }; + }); + const { deps, calls } = createDeps({ + settleOrdinaryOpenClawPairing: settleOrdinaryPairing, + verifyDeployment, + }); - await runFinalizationHandlers(baseOptions(deps)); + const finalization = runFinalizationHandlers(baseOptions(deps)); + await vi.waitFor(() => expect(events).toContain("pairing-started")); + expect(events).not.toContain("verify"); + releasePairing(); + await finalization; - expect(calls.settleOrdinaryPairing).toHaveBeenCalledExactlyOnceWith("my-assistant"); - expect(calls.settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeGreaterThan( + expect(settleOrdinaryPairing).toHaveBeenCalledExactlyOnceWith("my-assistant"); + expect(settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeGreaterThan( calls.recoverProcesses.mock.invocationCallOrder[0], ); - expect(calls.settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeLessThan( + expect(settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeLessThan( calls.recoverProcesses.mock.invocationCallOrder[1], ); expect(calls.recoverProcesses.mock.invocationCallOrder[1]).toBeLessThan( - calls.verify.mock.invocationCallOrder[0], + verifyDeployment.mock.invocationCallOrder[0], ); - }); - - it("does not settle ordinary OpenClaw pairing during an inner rebuild handoff (#9844)", async () => { - const { deps, calls } = createDeps(); - - const result = await runFinalizationHandlers({ - ...baseOptions(deps), - recreateJournalHandoff: true, - }); - - expect(result.stateResult.type).toBe("complete"); - expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); - expect(calls.ensureAgentDashboard).toHaveBeenCalledWith("my-assistant", null); - expect(calls.verify).toHaveBeenCalledOnce(); + expect(events).toEqual(["pairing-started", "pairing-settled", "verify"]); }); it("does not run OpenClaw pairing settlement for Hermes (#9844)", async () => { diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index 79102b78855..b1ad4ab9e2f 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -28,7 +28,6 @@ export interface FinalizationStateOptions | number; persistDashboardPort(sandboxName: string, dashboardPort: number): void; @@ -148,6 +147,18 @@ function selectedAgentName(agent: unknown): string | null { return typeof name === "string" && name.trim() === name && name ? name : null; } +function requiresOrdinaryOpenClawPairing( + portableAgent: PortableAgentDisposition, + agent: unknown, +): boolean { + // Rebuild recreates the container from the image, which wipes the + // machine-local pairing state (`identity` and `devices` are declared + // `backup: false` and removed on destroy). Both finalization phases must + // therefore apply the same pairing gate to rebuild and fresh onboarding + // (#10479). + return portableAgent === "ordinary" && selectedAgentName(agent) === "openclaw"; +} + function logTerminalReadyBlock( sandboxName: string, agent: unknown, @@ -178,7 +189,6 @@ export async function handleFinalizationState ({ - handleSandboxState: vi.fn(), - handleFinalizationState: vi.fn(), - handlePostVerifyState: vi.fn(), -})); - -vi.mock("./handlers/sandbox", async (importOriginal) => ({ - ...(await importOriginal()), - handleSandboxState: mocks.handleSandboxState, -})); - -vi.mock("./handlers/finalization", async (importOriginal) => ({ - ...(await importOriginal()), - handleFinalizationState: mocks.handleFinalizationState, - handlePostVerifyState: mocks.handlePostVerifyState, -})); - -import { createSandboxOnboardFlowPhase } from "./core-flow-phases"; -import { createFinalOnboardFlowPhases } from "./final-flow-phases"; -import type { OnboardFlowContext } from "./flow-context"; -import { advanceTo, branchTo, completeOnboardMachine } from "./result"; -import { createSession } from "../../state/onboard-session"; - -function context( - recreateJournalHandoff?: boolean, -): OnboardFlowContext> { - return { - resume: true, - fresh: false, - recreateJournalHandoff, - session: createSession(), - agent: null, - recordedSandboxName: "alpha", - requestedSandboxName: "alpha", - sandboxName: "alpha", - fromDockerfile: null, - model: "model-a", - provider: "nvidia", - endpointUrl: "https://integrate.api.nvidia.com/v1", - credentialEnv: "NVIDIA_API_KEY", - hermesAuthMethod: null, - hermesToolGateways: [], - preferredInferenceApi: "openai", - compatibleEndpointReasoning: null, - compatibleEndpointReasoningEffort: null, - nimContainer: null, - webSearchConfig: null, - webSearchSupported: false, - selectedMessagingChannels: [], - gpu: null, - sandboxGpuConfig: {}, - gpuPassthrough: false, - }; -} - -describe("rebuild pairing handoff", () => { - beforeEach(() => { - mocks.handleSandboxState.mockReset().mockResolvedValue({ - sandboxName: "alpha", - webSearchConfig: null, - webSearchConfigChanged: false, - hermesToolGateways: [], - selectedMessagingChannels: [], - webSearchSupported: false, - session: createSession(), - stateResult: branchTo("openclaw", { metadata: { state: "sandbox" } }), - }); - mocks.handleFinalizationState.mockReset().mockResolvedValue({ - stateResult: advanceTo("post_verify", { metadata: { state: "finalizing" } }), - unmigratedLegacyKeys: [], - }); - mocks.handlePostVerifyState.mockReset().mockResolvedValue({ - stateResult: completeOnboardMachine({}, { metadata: { state: "post_verify" } }), - verificationDiagnostics: [], - deploymentHealthy: true, - }); - }); - - it.each([ - { fingerprint: "intent-1", expected: true }, - { fingerprint: null, expected: false }, - ])( - "maps journal fingerprint $fingerprint to handoff=$expected (#9844)", - async ({ fingerprint, expected }) => { - const phase = createSandboxOnboardFlowPhase({ - gatewayName: "nemoclaw", - recreateJournalTargetIntentFingerprint: fingerprint, - resumeAgentChanged: false, - endpointProvenance: { getSandboxRegistryEntry: () => null }, - recreateSandbox: () => true, - controlUiPort: null, - rootDir: "/repo", - env: {}, - deps: {} as never, - }); - - const result = await phase.run(context()); - - expect(result.context.recreateJournalHandoff).toBe(expected); - expect(mocks.handleSandboxState).toHaveBeenCalledWith( - expect.objectContaining({ recreateJournalTargetIntentFingerprint: fingerprint }), - ); - }, - ); - - it.each([true, false])( - "passes handoff=%s from final-flow context to both final handlers (#9844)", - async (recreateJournalHandoff) => { - const phases = createFinalOnboardFlowPhases({ - branchState: "openclaw", - agentSetupDeps: {} as never, - policiesDeps: {} as never, - finalization: { - stagedLegacyKeys: [], - migratedLegacyKeys: new Set(), - webSearchEnabled: () => false, - webSearchProvider: () => "brave", - }, - finalizationDeps: {} as never, - }); - const finalContext = context(recreateJournalHandoff); - - await phases[2].run(finalContext); - await phases[3].run(finalContext); - - expect(mocks.handleFinalizationState).toHaveBeenCalledWith( - expect.objectContaining({ recreateJournalHandoff }), - ); - expect(mocks.handlePostVerifyState).toHaveBeenCalledWith( - expect.objectContaining({ recreateJournalHandoff }), - ); - }, - ); -}); diff --git a/test/e2e/live/rebuild-openclaw.test.ts b/test/e2e/live/rebuild-openclaw.test.ts index d733d4346ef..9e43be02126 100644 --- a/test/e2e/live/rebuild-openclaw.test.ts +++ b/test/e2e/live/rebuild-openclaw.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { shellQuote } from "../../../src/lib/core/shell-quote"; +import { containsAnswer } from "../../helpers/e2e-answer-assertions.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; import { assertExitZero as expectExitZero, resultText } from "../fixtures/clients/command.ts"; @@ -19,6 +20,7 @@ import { writeJsonFile, } from "../fixtures/file-state.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; +import { parseOpenClawAgentText } from "../fixtures/openclaw-agent-output.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { createOldBaseBuildContext } from "./rebuild-openclaw-old-base-context.ts"; @@ -750,6 +752,31 @@ print(json.dumps({'seeded': saved == os.environ['PRE_REBUILD_GATEWAY_TOKEN'], 'h // Phase 7: state preservation, upgrade, token rotation, backup hygiene, and // policy-preset preservation assertions. progress.phase("validate upgraded state policy inference and backup hygiene"); + const agentTurn = await host.nemoclaw( + [ + SANDBOX_NAME, + "agent", + "--agent", + "main", + "--json", + "--session-id", + `e2e-rebuild-oc-${Date.now()}-${process.pid}`, + "-m", + "What is 6 multiplied by 7? Reply with only the integer, no extra words.", + ], + { + artifactName: "phase-7-agent-inference-after-rebuild", + env: cliEnv(apiKey), + redactionValues: [apiKey], + timeoutMs: 120_000, + }, + ); + expectExitZero(agentTurn, "OpenClaw agent inference after rebuild"); + expect( + containsAnswer(parseOpenClawAgentText(agentTurn.stdout), "42"), + resultText(agentTurn), + ).toBe(true); + const markerRead = await sandbox.exec(SANDBOX_NAME, ["cat", MARKER_FILE], { artifactName: "phase-7-read-workspace-marker", env: dockerContextEnv(), diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 549583ff712..a9a31009a1a 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -683,6 +683,7 @@ "live": "test/e2e/live/rebuild-openclaw.test.ts", "fast": [ "src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts", + "src/lib/onboard/machine/handlers/finalization.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ]