Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
5bf10d6
fix(e2e): retry transient OpenClaw provider turns
cjagwani Sep 4, 2026
2c6c687
test(e2e): register provider retry parity
cjagwani Sep 4, 2026
65677ba
fix(e2e): structure provider retry signals
cjagwani Sep 4, 2026
5c8b008
merge: update provider retry repair from main
prekshivyas Sep 5, 2026
8c240fb
fix(forward): settle service startup safely
prekshivyas Sep 5, 2026
e916534
Merge remote-tracking branch 'origin/main' into pr-11077
prekshivyas Sep 5, 2026
b180da4
fix(e2e): make forward retries process-safe
prekshivyas Sep 5, 2026
5571a9b
fix(forward): verify process identity before cleanup
prekshivyas Sep 5, 2026
3668662
fix(forward): verify supported-host listener ownership
prekshivyas Sep 5, 2026
fdcd474
Merge remote-tracking branch 'origin/main' into pr-11077
prekshivyas Sep 5, 2026
f9fa066
fix(e2e): close forward retry validation gaps
prekshivyas Sep 5, 2026
57f938e
fix(forward): avoid activating readiness probes
prekshivyas Sep 5, 2026
741c539
fix(forward): restore readiness handoff retries
prekshivyas Sep 5, 2026
7de1f17
fix(forward): normalize wrapped readiness errors
prekshivyas Sep 5, 2026
3cdb354
fix(forward): normalize diagnostic layout
prekshivyas Sep 5, 2026
45c5a18
fix(e2e): require provider outage evidence
prekshivyas Sep 5, 2026
2d0df5e
fix(e2e): preserve retry cleanup budget
prekshivyas Sep 5, 2026
7ebb655
test(e2e): model empty provider turns
prekshivyas Sep 5, 2026
312b2ee
fix(forward): report scoped recovery state
prekshivyas Sep 5, 2026
03be604
fix(forward): preserve OpenShell config authority
prekshivyas Sep 5, 2026
0d1cfc7
fix(e2e): keep provider retries fail closed
prekshivyas Sep 5, 2026
26933c7
fix(forward): verify recovered listener identity
prekshivyas Sep 5, 2026
e1fc111
test(forward): use platform test gate
prekshivyas Sep 5, 2026
d4b602c
fix(forward): bound listener stability
prekshivyas Sep 5, 2026
4f9126d
fix(forward): verify launch listener authority
prekshivyas Sep 5, 2026
cebe90d
fix(e2e): block retry after cleanup failure
prekshivyas Sep 5, 2026
4302882
fix(e2e): narrow provider outage retry
prekshivyas Sep 8, 2026
d0bd3f4
fix(e2e): fail closed before provider retry
prekshivyas Sep 8, 2026
5154b00
chore: merge latest main into provider retry
prekshivyas Sep 8, 2026
7eb235e
test(e2e): close provider retry evidence gaps
prekshivyas Sep 8, 2026
4f3705a
chore: merge latest main into provider retry
prekshivyas Sep 8, 2026
f5817d1
fix(e2e): delimit provider retry marker
prekshivyas Sep 8, 2026
a357622
chore: merge latest main into provider retry
prekshivyas Sep 8, 2026
639a46e
chore: merge latest main into provider retry
prekshivyas Sep 8, 2026
86a13cc
fix(e2e): trust structured provider failures
prekshivyas Sep 8, 2026
254a16a
chore: merge main into provider availability fix
prekshivyas Sep 8, 2026
8892c34
fix(ci): preserve macOS E2E timeout headroom
prekshivyas Sep 8, 2026
2813f54
chore: merge latest main into provider availability fix
prekshivyas Sep 8, 2026
d730fe4
refactor(e2e): centralize full E2E timeout budget
prekshivyas Sep 8, 2026
ced63a2
test(e2e): stage timeout contract in advisor fixture
prekshivyas Sep 9, 2026
1d293d9
chore: merge latest main into provider availability fix
prekshivyas Sep 9, 2026
ea428b6
Merge origin/main into codex/fix-10978-provider-availability-20260904
prekshivyas Sep 9, 2026
ea2d353
fix(deps): require patched js-yaml
prekshivyas Sep 9, 2026
428db9a
Merge origin/main into codex/fix-10978-provider-availability-20260904
prekshivyas Sep 9, 2026
4c7c717
test(e2e): prove exhausted provider handoff
prekshivyas Sep 9, 2026
005a9a0
test(e2e): keep provider proof within guardrails
prekshivyas Sep 9, 2026
f6a376a
test(e2e): accept bounded provider diagnostics
prekshivyas Sep 9, 2026
fe8072e
Merge origin/main into codex/fix-10978-provider-availability-20260904
prekshivyas Sep 9, 2026
b7414a1
ci(e2e): bind portable timeout ownership
prekshivyas Sep 9, 2026
512f326
Merge origin/main into codex/fix-10978-provider-availability-20260904
prekshivyas Sep 9, 2026
a7dce15
fix(e2e): constrain provider retries and budgets
prekshivyas Sep 9, 2026
651937c
Merge origin/main into codex/fix-10978-provider-availability-20260904
prekshivyas Sep 9, 2026
80cb7c1
test(e2e): use typed standard timeout contract
prekshivyas Sep 9, 2026
b5a0c8c
Merge remote-tracking branch 'origin/main' into pr-11077-narrow
prekshivyas Sep 9, 2026
66cd7e0
fix(e2e): route timeout contract evidence
prekshivyas Sep 9, 2026
18befcf
test(e2e): cover accepted provider variants
prekshivyas Sep 9, 2026
8e8fdc9
chore(deps): drop unrelated lockfile drift
prekshivyas Sep 9, 2026
b6eaf1c
Merge remote-tracking branch 'origin/main' into pr-11077-narrow
prekshivyas Sep 9, 2026
4fe9f99
fix(e2e): complete timeout evidence routing
prekshivyas Sep 9, 2026
8e0f75b
fix(e2e): validate provider retry evidence
prekshivyas Sep 9, 2026
49e8658
fix(e2e): preserve fail-closed provider retries
prekshivyas Sep 9, 2026
399bef7
Merge origin/main into codex/fix-10978-provider-availability-20260904
prekshivyas Sep 9, 2026
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
7 changes: 5 additions & 2 deletions docs/get-started/quickstart-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,11 @@ Use these details when your first-run path needs more control.
The onboard flow starts both port forwards automatically.
For a new sandbox, NemoClaw reserves the selected dashboard loopback port through sandbox preparation and creation.
If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources.
If OpenShell returns the `sandbox is not ready` response, NemoClaw waits 5 seconds and retries the affected forward up to 12 times.
The readiness-specific delays total at most 1 minute and preserve the existing sandbox and selected host port.
NemoClaw waits up to 90 seconds for each forward to bind.
If the exact forward process exits before it binds, NemoClaw waits 1 second and retries once.
If the process remains unbound at the deadline, NemoClaw stops that exact process and confirms its exit before retrying.
The complete two-attempt sequence can take just over 3 minutes for each forward.
NemoClaw does not adopt an unrelated listener or start another forward while it cannot confirm that the prior process stopped.
NemoClaw releases the reservation immediately before OpenShell starts the dashboard forward.
If forwarding then fails, onboarding removes the new sandbox and tells you to resolve the reported error before retrying.
The Hermes dashboard URL does not include an OpenClaw `#token=` fragment.
Expand Down
7 changes: 5 additions & 2 deletions docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,11 @@ Use these details when your first-run path needs more control.
When that port is occupied, NemoClaw uses the next free dashboard port, such as `18790`, and includes the port in the URL.
For a new sandbox, NemoClaw reserves the selected loopback port through sandbox preparation and creation.
If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources.
If OpenShell returns the `sandbox is not ready` response, NemoClaw waits 5 seconds and retries the dashboard forward up to 12 times.
The readiness-specific delays total at most 1 minute and preserve the existing sandbox and selected port.
NemoClaw waits up to 90 seconds for the dashboard forward to bind.
If the exact forward process exits before it binds, NemoClaw waits 1 second and retries once.
If the process remains unbound at the deadline, NemoClaw stops that exact process and confirms its exit before retrying.
The complete two-attempt sequence can take just over 3 minutes.
NemoClaw does not adopt an unrelated listener or start another forward while it cannot confirm that the prior process stopped.
NemoClaw releases the reservation immediately before OpenShell starts the dashboard forward.
If forwarding then fails, onboarding removes the new sandbox and tells you to resolve the reported error before retrying.
The installation transcript does not print the gateway token.
Expand Down
154 changes: 150 additions & 4 deletions src/lib/adapters/openshell/forward-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const target: ForwardServiceTarget = {
targetHost: "127.0.0.1",
targetPort: 18_789,
};
const stableProcessIdentity = (pid: number): string => `start-${String(pid)}`;

describe("OpenShell forward service", () => {
it("builds the direct ForwardTcp command with explicit gateway authority", () => {
Expand Down Expand Up @@ -47,11 +48,13 @@ describe("OpenShell forward service", () => {

it("detaches the OpenShell child and waits for its local port", () => {
const unref = vi.fn();
const spawnDetached = vi.fn(() => ({ unref }));
const spawnDetached = vi.fn(() => ({ pid: 41, unref }));
let probes = 0;

launchForwardService(target, {
getProcessIdentity: stableProcessIdentity,
isReachable: () => ++probes >= 3,
isProcessRunning: () => true,
sleep: () => {},
spawnDetached,
timeoutMs: 1_000,
Expand All @@ -74,14 +77,157 @@ describe("OpenShell forward service", () => {
expect(spawnDetached).not.toHaveBeenCalled();
});

it("fails when the detached service does not bind before the deadline", () => {
it("stops a running service that remains unbound at the deadline (#11084)", () => {
const unboundTarget = { ...target, localPort: 18_791, targetPort: 18_791 };
let running = true;
const stopProcess = vi.fn(() => {
running = false;
});
expect(() =>
launchForwardService(unboundTarget, {
describeState: () => "SANDBOX BIND PORT PID STATUS",
getProcessIdentity: stableProcessIdentity,
isProcessRunning: () => running,
isReachable: () => false,
maxAttempts: 1,
sleep: () => {},
spawnDetached: () => ({ pid: 42, unref: () => {} }),
stopProcess,
timeoutMs: 0,
}),
).toThrow(/was stopped after failing to bind.*forward list: SANDBOX BIND PORT PID STATUS/u);
expect(stopProcess).toHaveBeenCalledWith(42, "SIGTERM");
});

it("retries only after the prior service process exited (#11084)", () => {
const spawnDetached = vi
.fn()
.mockReturnValueOnce({ pid: 51, unref: vi.fn() })
.mockReturnValueOnce({ pid: 52, unref: vi.fn() });
launchForwardService(target, {
getProcessIdentity: stableProcessIdentity,
isProcessRunning: (pid) => pid === 52,
isReachable: () => spawnDetached.mock.calls.length === 2,
retryDelayMs: 0,
sleep: () => {},
spawnDetached,
timeoutMs: 1_000,
});

expect(spawnDetached).toHaveBeenCalledTimes(2);
});

it("does not retry when the timed-out service cannot be stopped (#11084)", () => {
const settlingTarget = { ...target, localPort: 18_790, targetPort: 18_790 };
const spawnDetached = vi.fn(() => ({ pid: 61, unref: vi.fn() }));
const stopProcess = vi.fn();

expect(() =>
launchForwardService(settlingTarget, {
getProcessIdentity: stableProcessIdentity,
isProcessRunning: () => true,
isReachable: () => false,
sleep: () => {},
spawnDetached,
stopProcess,
stopTimeoutMs: 0,
timeoutMs: 0,
}),
).toThrow(/process 61.*could not stop.*refusing to signal or retry/u);
expect(spawnDetached).toHaveBeenCalledOnce();
expect(stopProcess.mock.calls).toEqual([
[61, "SIGTERM"],
[61, "SIGKILL"],
]);
});

it("confirms exit after escalating a timed-out service to SIGKILL (#11084)", () => {
const signals: NodeJS.Signals[] = [];
let killChecks = 0;

expect(() =>
launchForwardService(target, {
getProcessIdentity: stableProcessIdentity,
isProcessRunning: () =>
signals.at(-1) === "SIGKILL" ? ++killChecks < 2 : true,
isReachable: () => false,
maxAttempts: 1,
sleep: () => {},
spawnDetached: () => ({ unref: () => {} }),
spawnDetached: () => ({ pid: 62, unref: vi.fn() }),
stopProcess: (_pid, signal) => signals.push(signal),
stopTimeoutMs: 1_000,
timeoutMs: 0,
}),
).toThrow(/did not bind/u);
).toThrow(/was stopped after failing to bind/u);
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
expect(killChecks).toBe(2);
});

it("does not signal or retry after the forward PID is reused (#11084)", () => {
const spawnDetached = vi.fn(() => ({ pid: 63, unref: vi.fn() }));
const stopProcess = vi.fn();
let identityReads = 0;

expect(() =>
launchForwardService(target, {
getProcessIdentity: () => (++identityReads === 1 ? "original" : "replacement"),
isProcessRunning: () => true,
isReachable: () => false,
sleep: () => {},
spawnDetached,
stopProcess,
timeoutMs: 0,
}),
).toThrow(/process 63 changed identity.*refusing to signal or retry/u);
expect(stopProcess).not.toHaveBeenCalled();
expect(spawnDetached).toHaveBeenCalledOnce();
});

it("refuses an unknown listener that appears before a safe retry (#11084)", () => {
const spawnDetached = vi.fn(() => ({ pid: 71, unref: vi.fn() }));
let probes = 0;

expect(() =>
launchForwardService(target, {
getProcessIdentity: stableProcessIdentity,
isProcessRunning: () => false,
isReachable: () => ++probes >= 3,
retryDelayMs: 0,
sleep: () => {},
spawnDetached,
timeoutMs: 1_000,
}),
).toThrow(/became occupied.*refusing to adopt/u);
expect(spawnDetached).toHaveBeenCalledOnce();
});

it.runIf(process.platform === "linux")(
"tracks the spawned process by its Linux start time (#11084)",
() => {
let probes = 0;

launchForwardService(target, {
isReachable: () => ++probes >= 2,
spawnDetached: () => ({ pid: process.pid, unref: vi.fn() }),
});

expect(probes).toBe(2);
},
);

it("does not retry when the service returns no process identity (#11084)", () => {
const spawnDetached = vi.fn(() => ({ unref: vi.fn() }));

expect(() =>
launchForwardService(target, {
describeState: () => {
throw new Error("forward list failed");
},
isReachable: () => false,
sleep: () => {},
spawnDetached,
}),
).toThrow(/no process identity.*forward list: <unavailable>/u);
expect(spawnDetached).toHaveBeenCalledOnce();
});
});
Loading
Loading