From 064a5058ad18844a45870a164de1455b7bf8f121 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 12 Jun 2026 18:26:53 +0000 Subject: [PATCH] fix(sandbox): preserve persisted agent through registry recovery Signed-off-by: Tinson Lai --- src/lib/registry-recovery-action.test.ts | 62 ++++++++++++++++++++++++ src/lib/registry-recovery-action.ts | 13 ++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/lib/registry-recovery-action.test.ts b/src/lib/registry-recovery-action.test.ts index c26213ac457..ff6f5256b8b 100644 --- a/src/lib/registry-recovery-action.test.ts +++ b/src/lib/registry-recovery-action.test.ts @@ -123,6 +123,68 @@ describe("recoverRegistryEntries (#2753 seed-time guard)", () => { expect(result.sandboxes).toEqual([]); }); + it("preserves a persisted Hermes agent when the session re-seeds the same sandbox", async () => { + // A Hermes sandbox already in the registry must keep `agent: "hermes"` + // even when registry-recovery re-seeds from session metadata that has + // no agent field. Object.assign in updateSandbox would otherwise clobber + // the persisted agent to null, breaking rebuild-time agent resolution + // (state paths under /sandbox/.hermes-data versus /sandbox/.openclaw-data). + mockRegistryState.sandboxes["my-hermes"] = { + name: "my-hermes", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + gpuEnabled: false, + policies: ["npm", "pypi"], + nimContainer: null, + agent: "hermes", + agentVersion: "2026.5.16", + }; + vi.mocked(loadSession).mockReturnValue({ + sandboxName: "my-hermes", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + policyPresets: ["npm", "pypi"], + nimContainer: null, + agent: "hermes", + steps: { + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + } as never); + + await recoverRegistryEntries(); + + expect(mockRegistryState.sandboxes["my-hermes"]?.agent).toBe("hermes"); + expect(mockRegistryState.sandboxes["my-hermes"]?.agentVersion).toBe("2026.5.16"); + }); + + it("does not clobber a persisted agent when session metadata omits it", async () => { + // Defensive: even if a stale session has no `agent` field at all (older + // session format), recovery must not overwrite the persisted agent. + mockRegistryState.sandboxes["my-hermes"] = { + name: "my-hermes", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + gpuEnabled: false, + policies: [], + nimContainer: null, + agent: "hermes", + }; + vi.mocked(loadSession).mockReturnValue({ + sandboxName: "my-hermes", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + policyPresets: [], + nimContainer: null, + steps: { + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + } as never); + + await recoverRegistryEntries(); + + expect(mockRegistryState.sandboxes["my-hermes"]?.agent).toBe("hermes"); + }); + it("does not evict a registered sandbox even when its session step is incomplete (avoids false positives)", async () => { // A user with a real registered sandbox alpha and a stale session that // happens to record alpha with an incomplete sandbox step (e.g. a diff --git a/src/lib/registry-recovery-action.ts b/src/lib/registry-recovery-action.ts index a38168fcfbe..3d610fa82fa 100644 --- a/src/lib/registry-recovery-action.ts +++ b/src/lib/registry-recovery-action.ts @@ -23,7 +23,7 @@ function buildRecoveredSandboxEntry( name: string, metadata: RecoveredSandboxMetadata = {}, ): SandboxEntry { - return { + const entry: SandboxEntry = { name, model: metadata.model || null, provider: metadata.provider || null, @@ -34,8 +34,16 @@ function buildRecoveredSandboxEntry( ? metadata.policyPresets : [], nimContainer: metadata.nimContainer || null, - agent: metadata.agent || null, }; + // Only assert `agent` when recovery actually knows it. Object.assign in + // updateSandbox would otherwise overwrite a persisted agent (e.g. "hermes") + // with null whenever the recovery seed has no source of truth — the live + // OpenShell gateway does not surface NemoClaw's agent type, and a session + // sandbox seed never set this field, so the existing entry must win. + if (metadata.agent !== undefined && metadata.agent !== null) { + entry.agent = metadata.agent; + } + return entry; } function upsertRecoveredSandbox(name: string, metadata: RecoveredSandboxMetadata = {}) { @@ -109,6 +117,7 @@ function seedRecoveryMetadata( provider: session.provider || null, nimContainer: session.nimContainer || null, policyPresets: session.policyPresets || null, + agent: session.agent || null, }), ); const sessionSandboxMissing = !current.sandboxes.some(