Summary
The "brittle-session" contract is not enforced by the persistent state it relies on. After an action completes Failed/Canceled/Timeout, a Session is supposed to become permanently ending-only (only exit_environment/cleanup allowed, never returning to Ready). But the ending_only flag that is meant to enforce this is never set on any action-failure path, so a failed action followed by exit_environment(keep_session_running = true) (whose onExit succeeds, or which has no onExit) recomputes the session back to Ready and wrongly accepts new run_task / enter_environment calls.
Confirmed against main @ 99871a0293ef0ebfb791cdef6d38c639914463db. Crate: openjd-sessions (src/session.rs).
Expected vs. actual
specs/sessions/session.md § Brittle Sessions states the ending_only flag is set when "an action completes with ActionState::Failed, Canceled, or Timeout." In the code, self.ending_only = true is assigned in exactly one place — exit_environment when !keep_session_running (src/session.rs:1387). No action-finalize path sets it; they set only the transient self.state = SessionState::ReadyEnding.
The post-action state is then computed from the flag:
- normal
drive_action finalize (~session.rs:2060): state = if self.ending_only || final_state != Success { ReadyEnding } else { Ready }
- no-
onExit-script exit path (~session.rs:1562): state = if self.ending_only { ReadyEnding } else { Ready }
New actions are gated on state only (enter_environment, run_task, run_subprocess), never on ending_only.
Reproduction (public API)
- Enter an environment whose
onExit succeeds (or has none).
run_task(...) that fails → final_state = Failed → state = ReadyEnding, but ending_only stays false.
exit_environment(id, keep_session_running = true) → ending_only not set; onExit succeeds → final_state = Success → state recomputed to Ready.
- A subsequent
run_task / enter_environment is now accepted — the session has "recovered" from a state it should never leave.
Final state is Ready (bug); the contract requires ReadyEnding.
Suggested fix
Set self.ending_only = true on every action-finalize path that yields a non-Success terminal state (the normal drive_action finalize, the drive_action error path, fail_action_setup, and run_subprocess_via_helper), so the flag reflects the contract regardless of the later exit's keep_session_running value.
Reachability
The bundled openjd CLI does call exit_environment(..., keep_session_running = true, ...) during teardown (crates/openjd-cli/src/run/), but does not dispatch a new action afterward, so the CLI binary itself does not visibly misbehave. The defect is a violation of the Session public-API state-machine contract and is observable by any library consumer (e.g. a worker agent) that keeps a session running after a failed action.
How it was found
Surfaced during a formal-model audit of the sessions runtime: the model's state-machine spec requires the session to stay ending-only after any failure, and comparing that invariant against the code showed ending_only is never set on the failure paths. Verified by two independent adversarial code reviews against the sha above.
Summary
The "brittle-session" contract is not enforced by the persistent state it relies on. After an action completes
Failed/Canceled/Timeout, aSessionis supposed to become permanently ending-only (onlyexit_environment/cleanupallowed, never returning toReady). But theending_onlyflag that is meant to enforce this is never set on any action-failure path, so a failed action followed byexit_environment(keep_session_running = true)(whoseonExitsucceeds, or which has noonExit) recomputes the session back toReadyand wrongly accepts newrun_task/enter_environmentcalls.Confirmed against
main@99871a0293ef0ebfb791cdef6d38c639914463db. Crate:openjd-sessions(src/session.rs).Expected vs. actual
specs/sessions/session.md§ Brittle Sessions states theending_onlyflag is set when "an action completes withActionState::Failed,Canceled, orTimeout." In the code,self.ending_only = trueis assigned in exactly one place —exit_environmentwhen!keep_session_running(src/session.rs:1387). No action-finalize path sets it; they set only the transientself.state = SessionState::ReadyEnding.The post-action state is then computed from the flag:
drive_actionfinalize (~session.rs:2060):state = if self.ending_only || final_state != Success { ReadyEnding } else { Ready }onExit-script exit path (~session.rs:1562):state = if self.ending_only { ReadyEnding } else { Ready }New actions are gated on
stateonly (enter_environment,run_task,run_subprocess), never onending_only.Reproduction (public API)
onExitsucceeds (or has none).run_task(...)that fails →final_state = Failed→state = ReadyEnding, butending_onlystaysfalse.exit_environment(id, keep_session_running = true)→ending_onlynot set;onExitsucceeds →final_state = Success→ state recomputed toReady.run_task/enter_environmentis now accepted — the session has "recovered" from a state it should never leave.Final state is
Ready(bug); the contract requiresReadyEnding.Suggested fix
Set
self.ending_only = trueon every action-finalize path that yields a non-Successterminal state (the normaldrive_actionfinalize, thedrive_actionerror path,fail_action_setup, andrun_subprocess_via_helper), so the flag reflects the contract regardless of the later exit'skeep_session_runningvalue.Reachability
The bundled
openjdCLI does callexit_environment(..., keep_session_running = true, ...)during teardown (crates/openjd-cli/src/run/), but does not dispatch a new action afterward, so the CLI binary itself does not visibly misbehave. The defect is a violation of theSessionpublic-API state-machine contract and is observable by any library consumer (e.g. a worker agent) that keeps a session running after a failed action.How it was found
Surfaced during a formal-model audit of the sessions runtime: the model's state-machine spec requires the session to stay ending-only after any failure, and comparing that invariant against the code showed
ending_onlyis never set on the failure paths. Verified by two independent adversarial code reviews against the sha above.