Skip to content

refactor(onboard): make FSM resume recovery a single explicit path (#6227) - #6252

Closed
atulya-singh wants to merge 1 commit into
NVIDIA:mainfrom
atulya-singh:refactor/onboard-fsm-recovery-6227
Closed

refactor(onboard): make FSM resume recovery a single explicit path (#6227)#6252
atulya-singh wants to merge 1 commit into
NVIDIA:mainfrom
atulya-singh:refactor/onboard-fsm-recovery-6227

Conversation

@atulya-singh

@atulya-singh atulya-singh commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Foundational slice of #6227: replace the three implicit onboarding-recovery mechanisms with explicit, deterministic FSM semantics. Resume recovery becomes one validated, side-effect-free pass; terminal failures get a single idempotent owner; and the legal transition graph and terminality invariant are documented and enforced. This is the first of a planned phased series for #6227 and ships independently of the (out-of-scope) persisted create checkpoint.

Related Issue

Part of #6227. Also adds explicit enforcement for the invalid failed -> <agent> transition described in #6179.

Changes

  • Single recovery path — new src/lib/onboard/session-recovery.ts (planSessionRecovery / applySessionRecovery) classifies the durable snapshot, computes and validates a single legal non-terminal entry, re-seats the snapshot, and surfaces the decision so the caller emits exactly one explicit state.repair.completed event. Replaces the implicit repairResumeMachineSnapshot rewrite, which is removed; resumeMachineState is retained as an internal building block.
  • Single terminal-failure owner — new synchronous finalizeIncompleteOnboardStep in onboard-session.ts for exception/signal/nonzero-exit paths. It validates the <non-terminal> -> failed transition and is idempotent against an already-terminal machine, so exactly one failed transition and one terminal event are recorded. This removes the last production use of LEGACY_MACHINE_STEP_MUTATION_OPTIONS (the process-exit backstop in exit-step-failure.ts).
  • Documented + enforced graph — added a transition-graph/terminality-invariant doc block in transitions.ts and a negative test asserting a terminal failed state can never re-enter an agent/flow state ([DGX Spark][Onboard] nemoclaw onboard exits with InvalidOnboardMachineTransitionError after Ollama sandbox creation succeeds #6179).
  • Test callers migrated from repairResumeMachineSnapshot to applySessionRecovery; rebuild-flow terminal-failure mock updated to the new owner.

No persisted SandboxCreateIntent or public schema is introduced (explicit non-goal of #6227).

Type of Change

  • Code change (feature, bug fix, or refactor)

Quality Gates

  • Tests added or updated for changed behavior — new session-recovery.test.ts; new [DGX Spark][Onboard] nemoclaw onboard exits with InvalidOnboardMachineTransitionError after Ollama sandbox creation succeeds #6179 negative test; new idempotency test in exit-step-failure.test.ts; migrated resume/bootstrap/rebuild tests.
  • Docs not applicable — justification: internal FSM refactor with no user-facing behavior change (recovery outcomes and CLI surface are unchanged; only their internal mechanism is made explicit).
  • Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) — onboarding FSM recovery.
  • Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: requesting maintainer review of the onboarding recovery paths.

Verification

  • PR description includes the DCO sign-off declaration and every commit appears as Verified in GitHub
  • Targeted tests pass for changed behavior — full src/lib/onboard unit suite (203 files) plus onboard FSM/resume/lifecycle/rollback integration tests: 1899 passing. npm run typecheck:cli clean; Biome clean; npm run test:projects:check disjoint.
  • No secrets, API keys, or credentials committed

Note: commit/push git hooks were bypassed for the slow plugin-Vitest/plugin-typecheck steps only — the plugin (nemoclaw/) is untouched by this CLI-only diff and its node_modules is not installed in this environment. The CLI typecheck passed in the pre-push hook and in manual runs.


Signed-off-by: Atulya Singh atulyarajsingh@gmail.com

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a safer session recovery flow that can restore resumable state without mutating already valid sessions.
    • Added terminal failure handling for incomplete onboarding steps, with clearer state transitions and event recording.
  • Bug Fixes

    • Prevented terminal onboarding states from transitioning back into active flow states.
    • Made failure and recovery handling idempotent so repeated retries do not reapply changes.

…VIDIA#6227)

Replace the three implicit onboarding recovery mechanisms with explicit,
deterministic FSM semantics, the foundational slice of NVIDIA#6227.

- Consolidate resume repair into one validated, side-effect-free recovery
  pass (planSessionRecovery/applySessionRecovery in session-recovery.ts).
  It classifies the durable snapshot, computes and validates a single legal
  non-terminal entry, re-seats the snapshot, and surfaces the decision so the
  caller emits exactly one explicit state.repair.completed event. Replaces the
  implicit repairResumeMachineSnapshot rewrite.
- Introduce a single synchronous terminal-failure owner
  (finalizeIncompleteOnboardStep) for exception/signal/nonzero-exit paths. It
  validates the failed transition and is idempotent against an already-terminal
  machine, so exactly one failed transition and one terminal event are recorded.
  Removes the last production use of LEGACY_MACHINE_STEP_MUTATION_OPTIONS.
- Document the legal transition graph and terminality invariant, and assert
  that a terminal failed state can never re-enter an agent/flow state (NVIDIA#6179).

No persisted SandboxCreateIntent or schema change is introduced.

Signed-off-by: Atulya Singh <atulyarajsingh@gmail.com>

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces two legacy onboarding mechanisms with new dedicated modules: repairResumeMachineSnapshot is removed in favor of a new session-recovery.ts module (planSessionRecovery/applySessionRecovery), and markStepFailed is replaced with finalizeIncompleteOnboardStep for terminal step-failure handling. Related tests, bootstrap wiring, and transition documentation are updated accordingly.

Changes

Onboard session recovery and terminal failure refactor

Layer / File(s) Summary
Transition graph documentation and terminality tests
src/lib/onboard/machine/transitions.ts, src/lib/onboard/machine/transitions.test.ts
Documents transition graph invariants (terminal states, no failed->agent edges) and adds tests confirming failed blocks re-entry.
New session-recovery module
src/lib/onboard/session-recovery.ts, src/lib/onboard/session-recovery.test.ts
Adds SessionRecoveryPlan, UnrecoverableSessionError, assertRecoverableEntry, planSessionRecovery, and applySessionRecovery with unit tests.
Removal of legacy repair function
src/lib/onboard/resume-machine-repair.ts, src/lib/onboard/resume-machine-repair.test.ts
Removes repairResumeMachineSnapshot and migrates tests to applySessionRecovery.
Session bootstrap recovery wiring
src/lib/onboard/session-bootstrap.ts, src/lib/onboard/session-bootstrap.test.ts
Adds applySessionRecovery dependency and recovery result field; prepareResumeSession/prepareFreshSession return the recovery plan.
onboard.ts recovery event recording
src/lib/onboard.ts, src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts
Wires applySessionRecovery into bootstrap deps and records a state.repair.completed event when recovery occurs.
finalizeIncompleteOnboardStep implementation
src/lib/state/onboard-session.ts
Adds finalizeIncompleteOnboardStep to validate transitions, mark a step failed, and emit terminal-failure events.
exit-step-failure routing
src/lib/onboard/exit-step-failure.ts, src/lib/onboard/exit-step-failure.test.ts
Updates the deps contract and markLastStartedStepFailed to use finalizeIncompleteOnboardStep.
Rebuild flow test harness migration
src/lib/actions/sandbox/rebuild-flow.test.ts
Replaces markStepFailedSpy with finalizeIncompleteOnboardStepSpy, including idempotency for terminal states.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant applySessionRecovery
  participant planSessionRecovery
  participant classifyResumeMachineRepair
  participant resumeMachineState
  Caller->>applySessionRecovery: applySessionRecovery(session, stateEnteredAt)
  applySessionRecovery->>planSessionRecovery: planSessionRecovery(session)
  planSessionRecovery->>classifyResumeMachineRepair: classifyResumeMachineRepair(session)
  classifyResumeMachineRepair-->>planSessionRecovery: decision
  alt decision requires recovery
    planSessionRecovery->>resumeMachineState: resumeMachineState(session)
    resumeMachineState-->>planSessionRecovery: candidate entry
    planSessionRecovery-->>applySessionRecovery: recover plan (entry, reason)
    applySessionRecovery->>applySessionRecovery: mutate session.machine
  else keep
    planSessionRecovery-->>applySessionRecovery: keep plan
  end
  applySessionRecovery-->>Caller: SessionRecoveryPlan
Loading
sequenceDiagram
  participant Caller
  participant markLastStartedStepFailed
  participant finalizeIncompleteOnboardStep
  participant SessionStore
  participant EventEmitter
  Caller->>markLastStartedStepFailed: markLastStartedStepFailed(message)
  markLastStartedStepFailed->>finalizeIncompleteOnboardStep: finalizeIncompleteOnboardStep(stepName, message)
  finalizeIncompleteOnboardStep->>SessionStore: load session
  alt session missing or machine terminal
    finalizeIncompleteOnboardStep-->>markLastStartedStepFailed: null
  else
    finalizeIncompleteOnboardStep->>SessionStore: updateSession(mark step failed, machine -> failed)
    SessionStore-->>finalizeIncompleteOnboardStep: updated session
    finalizeIncompleteOnboardStep->>EventEmitter: emit state.failed, onboard.failed
    finalizeIncompleteOnboardStep-->>markLastStartedStepFailed: updated session
  end
Loading

Possibly related PRs

  • NVIDIA/NemoClaw#4472: Both PRs modify src/lib/onboard.ts's --resume bootstrap logic, transitioning between repairResumeMachineSnapshot and applySessionRecovery wiring.

Suggested labels: refactor, area: onboarding

Suggested reviewers: jyaunches, ericksoa

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main onboarding FSM recovery refactor and matches the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@atulya-singh

Copy link
Copy Markdown
Contributor Author

Superseded by #6253 (identical change, reopened to drop the co-author trailer).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/onboard.ts (1)

4769-4804: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move the exit-failure backstop before session recovery
applySessionRecovery() can throw UnrecoverableSessionError during resume bootstrap, but registerIncompleteOnboardExitHandlerForSession() is only installed later. That leaves an unrecoverable resumed session to exit without the single failure owner recording a failed transition or terminal event. Move the backstop earlier, or route recovery errors through the same failure path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` around lines 4769 - 4804, The resume bootstrap in
onboardSessionBootstrap.prepareOnboardSession can throw
UnrecoverableSessionError before registerIncompleteOnboardExitHandlerForSession
is installed, leaving recovery failures without the normal terminal failure
handling. Move the exit-failure backstop to run before prepareOnboardSession, or
make applySessionRecovery / the recovery path in onboard.ts route errors through
the same failure owner used by the session exit handler so failed resumed
sessions always record a failed transition or terminal event.
🧹 Nitpick comments (3)
src/lib/actions/sandbox/rebuild-flow.test.ts (1)

140-167: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock re-implements production mutation logic and diverges from it on the missing-step path.

This mock duplicates finalizeIncompleteOnboardStep's failure-recording/transition logic instead of delegating to it, and it diverges on the missing-step case: the real implementation no-ops entirely when session.steps[stepName] is absent (see src/lib/state/onboard-session.ts Line 1256), but this mock creates a pending step and proceeds to mark it failed anyway (Lines 152-154). Tests built on this double can't catch the missing-step no-op gap flagged in onboard-session.ts.

As per path instructions, "Flag copied production algorithms, broad mocks that bypass the behavior under test, and conditionals that make a test pass without exercising its claim."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/actions/sandbox/rebuild-flow.test.ts` around lines 140 - 167, The
test helper installTerminalStepFailureMock is re-implementing
finalizeIncompleteOnboardStep instead of exercising the real behavior, so it
hides the missing-step no-op path. Update the mock to delegate to the
onboardSession.finalizeIncompleteOnboardStep implementation (or assert its call
behavior) rather than mutating session state directly, and remove the
createStep("pending") fallback so absent session.steps[stepName] remains a no-op
just like production.

Source: Path instructions

src/lib/onboard/session-recovery.test.ts (1)

29-133: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add integration coverage for the unrecoverable-session throw path.

assertRecoverableEntry throwing is tested standalone, but there's no test proving planSessionRecovery/applySessionRecovery actually surface UnrecoverableSessionError for a session whose derived resume entry resolves to a terminal state (e.g., a failed session whose last completed step is the final step). This is the critical safety net the whole recovery redesign depends on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard/session-recovery.test.ts` around lines 29 - 133, Add an
integration-style test in session-recovery.test.ts that exercises the
unrecoverable path through planSessionRecovery and/or applySessionRecovery, not
just assertRecoverableEntry directly. Create a session whose derived resume
entry resolves to a terminal state (for example, a failed session where the last
completed step is the final step) and verify the call throws
UnrecoverableSessionError. Use the existing helpers and the
planSessionRecovery/applySessionRecovery symbols to confirm the throw is
surfaced end-to-end.
src/lib/onboard/session-bootstrap.test.ts (1)

122-123: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer an observable-outcome assertion over a mock-call assertion.

expect(deps.applySessionRecovery).toHaveBeenCalledWith(initial) locks in an implementation detail (that the dependency was invoked with this reference) rather than an observable behavior. Since the actual correctness invariant here is that recovery must see the session's failure/lastStepStarted fields before they're cleared, consider having the mock capture and assert on the state it was invoked with (e.g., its failure/status snapshot) so the test fails if that ordering regresses, instead of just checking the call occurred.

As per path instructions, "Prefer observable outcomes through the public boundary over source-text, private-shape, or mock-call assertions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard/session-bootstrap.test.ts` around lines 122 - 123, The test
is asserting a mock call detail instead of the observable recovery state, which
makes it brittle to implementation changes. Update the session bootstrap test
around applySessionRecovery so it captures the argument passed into that
dependency and asserts the session snapshot still contains the expected
failure/status/lastStepStarted information before cleanup, using the session
bootstrap flow and recovery result as the public behavior check rather than only
toHaveBeenCalledWith(initial).

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/lib/onboard.ts`:
- Around line 4769-4804: The resume bootstrap in
onboardSessionBootstrap.prepareOnboardSession can throw
UnrecoverableSessionError before registerIncompleteOnboardExitHandlerForSession
is installed, leaving recovery failures without the normal terminal failure
handling. Move the exit-failure backstop to run before prepareOnboardSession, or
make applySessionRecovery / the recovery path in onboard.ts route errors through
the same failure owner used by the session exit handler so failed resumed
sessions always record a failed transition or terminal event.

---

Nitpick comments:
In `@src/lib/actions/sandbox/rebuild-flow.test.ts`:
- Around line 140-167: The test helper installTerminalStepFailureMock is
re-implementing finalizeIncompleteOnboardStep instead of exercising the real
behavior, so it hides the missing-step no-op path. Update the mock to delegate
to the onboardSession.finalizeIncompleteOnboardStep implementation (or assert
its call behavior) rather than mutating session state directly, and remove the
createStep("pending") fallback so absent session.steps[stepName] remains a no-op
just like production.

In `@src/lib/onboard/session-bootstrap.test.ts`:
- Around line 122-123: The test is asserting a mock call detail instead of the
observable recovery state, which makes it brittle to implementation changes.
Update the session bootstrap test around applySessionRecovery so it captures the
argument passed into that dependency and asserts the session snapshot still
contains the expected failure/status/lastStepStarted information before cleanup,
using the session bootstrap flow and recovery result as the public behavior
check rather than only toHaveBeenCalledWith(initial).

In `@src/lib/onboard/session-recovery.test.ts`:
- Around line 29-133: Add an integration-style test in session-recovery.test.ts
that exercises the unrecoverable path through planSessionRecovery and/or
applySessionRecovery, not just assertRecoverableEntry directly. Create a session
whose derived resume entry resolves to a terminal state (for example, a failed
session where the last completed step is the final step) and verify the call
throws UnrecoverableSessionError. Use the existing helpers and the
planSessionRecovery/applySessionRecovery symbols to confirm the throw is
surfaced end-to-end.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 7f3dc939-7947-42e2-87cb-5b397c7b231f

📥 Commits

Reviewing files that changed from the base of the PR and between 6092ad2 and 88ffe8e.

📒 Files selected for processing (14)
  • src/lib/actions/sandbox/rebuild-flow.test.ts
  • src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts
  • src/lib/onboard.ts
  • src/lib/onboard/exit-step-failure.test.ts
  • src/lib/onboard/exit-step-failure.ts
  • src/lib/onboard/machine/transitions.test.ts
  • src/lib/onboard/machine/transitions.ts
  • src/lib/onboard/resume-machine-repair.test.ts
  • src/lib/onboard/resume-machine-repair.ts
  • src/lib/onboard/session-bootstrap.test.ts
  • src/lib/onboard/session-bootstrap.ts
  • src/lib/onboard/session-recovery.test.ts
  • src/lib/onboard/session-recovery.ts
  • src/lib/state/onboard-session.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants