Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,14 @@ The rerun takes one of these actions:
- It continues deletion when the live sandbox still has the journaled source identity.
- It continues creation when the recorded OpenShell gateway explicitly reports the source sandbox as absent.
- It accepts an existing replacement only when its live identity and sandbox registry generation match the journal.
- It starts a new replacement when the journaled replacement provably never took effect.

NemoClaw treats a journaled replacement as void when the sandbox registry row and the live same-name sandbox report the same OpenShell identity, and that identity is not the journaled replacement.
The rerun then reports `Discarded the void replacement journal for '<name>'` and records a fresh journal against that live source.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
A journal whose replacement never took therefore stops blocking `rebuild` for the rest of the session.

The journal binds the durable source registry row, so it does not resume after a command that rewrites that row.
Messaging channel state is exempt: `channels add`, `channels stop`, `channels start`, and `channels remove` rewrite the recorded messaging plan, and the journal ignores that field.

A mount-free journal written before NemoClaw bound host-mount identity remains resumable.
An older journal for a rebuild with one or more host mounts stops as incompatible, even when the visible mount settings are unchanged, because it cannot prove the original host source identity.
Expand Down
33 changes: 33 additions & 0 deletions src/lib/actions/sandbox/rebuild-recreate-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock("../../onboard/gateway-teardown-authority", async (importOriginal) => ({
resolveGatewayRebuildAuthority: mocks.resolveGatewayRebuildAuthority,
}));

import { fingerprintSandboxRecreateValue } from "../../onboard/sandbox-recreate-transaction";
import type { CheckpointGatewayAuthority } from "../../state/onboard-checkpoint-types";
import type { Session } from "../../state/onboard-session";
import * as onboardSession from "../../state/onboard-session";
Expand Down Expand Up @@ -357,6 +358,38 @@ describe("rebuild replacement journal", () => {
expect(session.checkpoint?.sandboxRecreate?.sourceLiveIdentityFingerprint).toBeNull();
});

it("starts a fresh journal when the stranded one no longer owns a replacement (#10473)", () => {
mocks.captureOpenshell.mockReturnValue(absentProbe());
const stranded = open();
expect(session.checkpoint?.sandboxRecreate).toMatchObject({
id: stranded.id,
phase: "deleted",
});

// The source was never deleted: OpenShell reports it again, and the
// preserved registry row still identifies that same live sandbox.
mocks.captureOpenshell.mockReturnValue(livePresentProbe());
vi.spyOn(registry, "getSandbox").mockReturnValue({
name: "alpha",
agent: "langchain-deepagents-code",
gatewayName: "nemoclaw-9090",
gatewayPort: 9090,
lifecycleGeneration: "44444444-4444-4444-8444-444444444444",
lifecycleLiveIdentityFingerprint: fingerprintSandboxRecreateValue(SANDBOX_ID),
} as registry.SandboxEntry);

const restarted = open();

expect(restarted.id).not.toBe(stranded.id);
expect(restarted.acceptedTarget).toBe(false);
expect(restarted.sourceConfirmedAbsent).toBe(false);
expect(session.checkpoint?.sandboxRecreate).toMatchObject({
id: restarted.id,
phase: "planned",
revision: 0,
});
});

it("records the delete boundary before and after the destructive command", () => {
const journal = open();

Expand Down
17 changes: 17 additions & 0 deletions src/lib/actions/sandbox/rebuild-recreate-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
advanceSandboxRecreateTransaction,
beginSandboxRecreateTransaction,
clearCompletedSandboxRecreateTransaction,
discardVoidSandboxRecreateTransaction,
fingerprintSandboxRecreateValue,
planSandboxRecreateRecovery,
type SandboxRecreateSourcePresence,
Expand Down Expand Up @@ -169,6 +170,22 @@ export function openRebuildRecreateJournal(
`Cannot resume sandbox '${target.sandboxName}' replacement: ${recovery.reason}.`,
);
}
// The recorded replacement never took: the registry row and the live
// same-name sandbox name one identity, so this rebuild starts a fresh
// transaction against that source instead of inheriting a journal it can
// never resume (#10473).
if (recovery.action === "restart_from_source" && active) {
onboardSession.updateSession((current) => {
discardVoidSandboxRecreateTransaction(current, active.id, observation, sourceEntry);
return current;
});
log(
`Discarded void replacement journal ${active.id} for '${target.sandboxName}' at phase '${active.phase}'; its source sandbox is still registered and live`,
);
console.log(
` Discarded the void replacement journal for '${target.sandboxName}'; its source sandbox is still registered and live.`,
);
}
// A registered, ready same-name sandbox carrying the journaled target
// generation and identity is the replacement this rebuild already proved.
// The caller must retire the transaction instead of deleting it again.
Expand Down
28 changes: 28 additions & 0 deletions src/lib/onboard/onboard-recreate-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,4 +352,32 @@ describe("non-resumed onboard replacement journal (#7735)", () => {
expect(session.checkpoint?.sandboxRecreate?.phase).toBe("deleted");
expect(session.checkpoint?.sandboxRecreate?.sourceLiveIdentityFingerprint).toBeNull();
});

it("starts a fresh journal when the stranded one no longer owns a replacement (#10473)", () => {
mocks.captureOpenshell.mockReturnValue(absentProbe());
const stranded = open();
expect(session.checkpoint?.sandboxRecreate?.phase).toBe("deleted");

// The source was never deleted: OpenShell reports it again, and the
// preserved registry row still identifies that same live sandbox.
mocks.captureOpenshell.mockReturnValue(livePresentProbe());
vi.spyOn(registry, "getSandbox").mockReturnValue({
name: "alpha",
agent: "openclaw",
gatewayName: "nemoclaw-9090",
gatewayPort: 9090,
lifecycleGeneration: "44444444-4444-4444-8444-444444444444",
lifecycleLiveIdentityFingerprint: SANDBOX_FINGERPRINT,
} as registry.SandboxEntry);

const restarted = open();

expect(restarted.targetGeneration).not.toBe(stranded.targetGeneration);
expect(restarted.acceptedTarget).toBe(false);
expect(session.checkpoint?.sandboxRecreate).toMatchObject({
phase: "planned",
revision: 0,
sourceLiveIdentityFingerprint: SANDBOX_FINGERPRINT,
});
});
});
12 changes: 12 additions & 0 deletions src/lib/onboard/onboard-recreate-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
beginSandboxRecreateTransaction,
clearCompletedSandboxRecreateTransaction,
createSandboxRecreateRuntime,
discardVoidSandboxRecreateTransaction,
fingerprintSandboxRecreateValue,
planSandboxRecreateRecovery,
type SandboxRecreateRuntime,
Expand Down Expand Up @@ -99,6 +100,17 @@ export function openOnboardRecreateJournal(
`Cannot resume sandbox '${target.sandboxName}' replacement: ${recovery.reason}.`,
);
}
// The recorded replacement never took, so this run owns a fresh transaction
// against the live source rather than a journal it can never resume (#10473).
if (recovery.action === "restart_from_source") {
onboardSession.updateSession((current) => {
discardVoidSandboxRecreateTransaction(current, active.id, observation, sourceEntry);
return current;
});
note(
` Discarded the void replacement journal ${active.id} for '${target.sandboxName}'; its source sandbox is still registered and live.`,
);
}
}

const session = onboardSession.updateSession((current) => {
Expand Down
158 changes: 150 additions & 8 deletions src/lib/onboard/sandbox-recreate-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,58 @@ const RECEIPT_BOUND_PROJECTION_FIELDS: readonly (keyof SandboxEntry)[] = [
"policies",
"customPolicies",
"mcp",
// `messaging` is a rehydrated projection, not durable sandbox identity: the
// channel commands own it (`channels add|stop|start|remove` rewrite the plan
// workflow label, disabledChannels, and the derived per-channel active,
// disabled, and hostForward fields), and loading the registry re-derives the
// rest of it from the built-in channel manifests and the ambient environment.
// Binding it made a `channels stop` plus `channels start` between two rebuilds
// change the fingerprint of an untouched sandbox, which left every later
// rebuild rejecting its own journal (#10473).
"messaging",
];

export function fingerprintSandboxRegistryEntry(entry: SandboxEntry): string {
function fingerprintDurableSandboxEntry(
entry: SandboxEntry,
excluded: readonly (keyof SandboxEntry)[],
): string {
const durable: Record<string, unknown> = { ...entry };
for (const field of [...ROUTE_RESERVATION_FIELDS, ...RECEIPT_BOUND_PROJECTION_FIELDS]) {
delete durable[field];
}
for (const field of excluded) delete durable[field];
return fingerprintSandboxRecreateValue(durable);
}

export function fingerprintSandboxRegistryEntry(entry: SandboxEntry): string {
return fingerprintDurableSandboxEntry(entry, [
...ROUTE_RESERVATION_FIELDS,
...RECEIPT_BOUND_PROJECTION_FIELDS,
]);
}

/**
* Accept a source row against a journal written before `messaging` left the
* durable fingerprint.
*
* Such a journal recorded a digest that still covered the messaging projection,
* so recomputing it the new way never matches. Because a journal parked past the
* delete boundary outlives an upgrade, refusing it would strand a rebuild whose
* source sandbox is already deleted. The compatibility digest reproduces the
* exact pre-#10473 field set, so it accepts only what the previous release
* already accepted.
*/
function sandboxRecreateSourceRowMatches(
entry: SandboxEntry | null,
recordedFingerprint: string,
): boolean {
if (!entry) return recordedFingerprint === fingerprintSandboxRecreateValue(null);
if (fingerprintSandboxRegistryEntry(entry) === recordedFingerprint) return true;
return (
fingerprintDurableSandboxEntry(entry, [
...ROUTE_RESERVATION_FIELDS,
...RECEIPT_BOUND_PROJECTION_FIELDS.filter((field) => field !== "messaging"),
]) === recordedFingerprint
);
}

export function fingerprintSandboxLiveIdentity(getOutput: string): string | null {
const clean = String(getOutput).replace(/\x1b\[[0-9;]*m/g, "");
const match = clean.match(/^\s*Id:\s+(\S+)\s*$/im);
Expand Down Expand Up @@ -453,7 +495,7 @@ export function assertSandboxRecreateSourceProof(
);
}
if (!check.registryEntry) return fail("the source registry row is absent");
if (fingerprintSandboxRegistryEntry(check.registryEntry) !== proof.sourceRegistryFingerprint) {
if (!sandboxRecreateSourceRowMatches(check.registryEntry, proof.sourceRegistryFingerprint)) {
return fail("the source registry row changed after the transaction recorded it");
}
if (check.observation.state === "missing") {
Expand Down Expand Up @@ -686,6 +728,42 @@ export function abandonSandboxRecreateTransaction(session: Session, id: string):
};
}

/**
* Retire a journal that `planSandboxRecreateRecovery` decided is void.
*
* Unlike `abandonSandboxRecreateTransaction` this accepts a journal that already
* advanced past `planned`, because the decision that reaches here proved the
* recorded replacement never took: the registry row and the live same-name
* sandbox name one identity. Re-presenting that proof keeps the discard bound to
* that decision instead of becoming a general escape hatch for a future caller.
* It also re-reads the journal from the session it is handed, so a journal that
* changed since the decision is not retired.
*/
export function discardVoidSandboxRecreateTransaction(
session: Session,
id: string,
observation: SandboxRecreateObservation,
registryEntry: SandboxEntry | null,
): void {
const checkpoint = baseCheckpoint(session);
const current = checkpoint.sandboxRecreate;
if (!current || current.id !== id) {
throw new Error("Sandbox recreate transaction ownership changed and cannot be discarded.");
}
if (!replacementIsVoid(current, observation, registryEntry)) {
throw new Error(
`Sandbox '${current.sandboxName}' recreate transaction still owns a replacement and cannot be discarded.`,
);
}
const now = new Date().toISOString();
session.checkpoint = {
...checkpoint,
machineState: session.machine.state,
updatedAt: now,
sandboxRecreate: null,
};
}

export function clearCompletedSandboxRecreateTransaction(session: Session, id: string): void {
const checkpoint = baseCheckpoint(session);
const current = checkpoint.sandboxRecreate;
Expand All @@ -705,12 +783,48 @@ export type SandboxRecreateRecoveryPlan =
| { readonly action: "continue_delete" }
| { readonly action: "continue_create" }
| { readonly action: "accept_target" }
| { readonly action: "restart_from_source" }
| { readonly action: "reject"; readonly reason: string };

function reject(reason: string): SandboxRecreateRecoveryPlan {
return { action: "reject", reason };
}

/**
* A journal whose replacement provably never happened.
*
* The caller has already ruled out the registered replacement, so the journal
* still claims a replacement is in flight. When the registry row and a live
* same-name sandbox name the same OpenShell identity, that claim is false: the
* sandbox this row describes is intact and there is no unregistered replacement
* to converge on. The recorded replacement is void, and the owner may retire it
* and open a fresh transaction against the live source instead of refusing every
* later rebuild for the rest of the session (#10473).
*
* The identity equality is what makes this safe. A replacement that was created
* but not yet registered carries a fresh OpenShell Id while the preserved row
* still carries the source's, so it can never satisfy this and stays protected
* by the fail-closed refusals in `planUnregisteredReplacementRecovery`.
*
* The name equality binds the evidence to the journal. `checkpoint.sandboxRecreate`
* holds one journal for the whole session, so a caller can present the row and
* observation of a different sandbox; that pairing must keep refusing rather than
* retire a journal protecting another sandbox's replacement.
*/
function replacementIsVoid(
transaction: CheckpointSandboxRecreateTransaction,
observation: SandboxRecreateObservation,
registryEntry: SandboxEntry | null,
): boolean {
return Boolean(
registryEntry?.name === transaction.sandboxName &&
registryEntry.lifecycleLiveIdentityFingerprint &&
observation.state !== "missing" &&
observation.liveIdentityFingerprint === registryEntry.lifecycleLiveIdentityFingerprint &&
observation.liveIdentityFingerprint !== transaction.targetLiveIdentityFingerprint,
);
}

export function planSandboxRecreateRecovery(
transaction: CheckpointSandboxRecreateTransaction,
observation: SandboxRecreateObservation,
Expand All @@ -734,9 +848,30 @@ export function planSandboxRecreateRecovery(
return { action: "accept_target" };
}

const sourceStateUnchanged = registryEntry
? fingerprintSandboxRegistryEntry(registryEntry) === transaction.sourceRegistryFingerprint
: transaction.sourceRegistryFingerprint === fingerprintSandboxRecreateValue(null);
const unregistered = planUnregisteredReplacementRecovery(transaction, observation, registryEntry);
// Every refusal below is terminal for the session: no command retires a
// journal the guard already refused to open. Downgrade one to a restart when
// the live sandbox and the registry row prove the recorded replacement never
// took, so a void journal stops blocking rebuild forever (#10473).
if (
unregistered.action === "reject" &&
replacementIsVoid(transaction, observation, registryEntry)
) {
return { action: "restart_from_source" };
}
return unregistered;
}

/** The recovery decision for a journal whose replacement is not registered. */
function planUnregisteredReplacementRecovery(
transaction: CheckpointSandboxRecreateTransaction,
observation: SandboxRecreateObservation,
registryEntry: SandboxEntry | null,
): SandboxRecreateRecoveryPlan {
const sourceStateUnchanged = sandboxRecreateSourceRowMatches(
registryEntry,
transaction.sourceRegistryFingerprint,
);
if (transaction.phase === "completed") {
return reject("the completed transaction no longer matches its replacement registry row");
}
Expand Down Expand Up @@ -885,6 +1020,13 @@ export function createSandboxRecreateRuntime(
if (recovery.action === "reject") {
throw new Error(`Cannot resume sandbox '${sandboxName}' recreation: ${recovery.reason}.`);
}
// A handed-off runtime does not own the journal it was given, so it cannot
// retire a void one. Its callers discard that journal before handing off.
if (recovery.action === "restart_from_source") {
throw new Error(
`Cannot resume sandbox '${sandboxName}' recreation: its journal no longer owns a replacement.`,
);
}
if (recovery.action === "accept_target") {
note(` [resume] Recovering journaled replacement sandbox '${sandboxName}'.`);
} else if (
Expand Down
Loading
Loading