Prevent cross-worktree session bleed during managed session resume/load

ROADMAP #41 was still leaving a phantom-completion class open: managed
sessions could be resumed from the wrong workspace, and the CLI/runtime
paths were split between partially isolated storage and older helper
flows. This squashes the verified team work into one deliverable that
routes managed session operations through the per-worktree SessionStore,
rejects workspace mismatches explicitly, extends lane-event taxonomy for
workspace mismatch reporting, and updates the affected CLI regression
fixtures/docs so the new contract is enforced without losing same-
workspace legacy coverage.

Constraint: Keep same-workspace legacy flat sessions readable while blocking cross-worktree misuse
Constraint: No new dependencies; stay within the ROADMAP #41 changed-file scope
Rejected: Leave team auto-checkpoint history as final branch state | noisy/non-lore history for a single roadmap fix
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve workspace_root validation on future resume/load helpers; do not reintroduce path-only fallback without equivalent mismatch checks
Tested: cargo test -p runtime session_control -- --nocapture; cargo test -p rusty-claude-cli resume -- --nocapture; cargo test -p rusty-claude-cli --test cli_flags_and_config_defaults; cargo test -p rusty-claude-cli --test output_format_contract; cargo test -p rusty-claude-cli --test resume_slash_commands; cargo test --workspace --exclude compat-harness; cargo check --workspace --all-targets; git diff --check
Not-tested: cargo clippy --workspace --all-targets -- -D warnings (pre-existing failures in unchanged rust/crates/rusty-claude-cli/build.rs)
Related: ROADMAP #41
This commit is contained in:
Yeachan-Heo
2026-04-11 16:08:01 +00:00
parent 56218d7d8a
commit 61c01ff7da
8 changed files with 464 additions and 429 deletions

View File

@@ -36,6 +36,8 @@ pub enum LaneEventName {
Closed,
#[serde(rename = "branch.stale_against_main")]
BranchStaleAgainstMain,
#[serde(rename = "branch.workspace_mismatch")]
BranchWorkspaceMismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -67,6 +69,7 @@ pub enum LaneFailureClass {
McpHandshake,
GatewayRouting,
ToolRuntime,
WorkspaceMismatch,
Infra,
}
@@ -277,6 +280,10 @@ mod tests {
LaneEventName::BranchStaleAgainstMain,
"branch.stale_against_main",
),
(
LaneEventName::BranchWorkspaceMismatch,
"branch.workspace_mismatch",
),
];
for (event, expected) in cases {
@@ -300,6 +307,7 @@ mod tests {
(LaneFailureClass::McpHandshake, "mcp_handshake"),
(LaneFailureClass::GatewayRouting, "gateway_routing"),
(LaneFailureClass::ToolRuntime, "tool_runtime"),
(LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"),
(LaneFailureClass::Infra, "infra"),
];
@@ -329,6 +337,38 @@ mod tests {
assert_eq!(failed.detail.as_deref(), Some("broken server"));
}
#[test]
fn workspace_mismatch_failure_class_round_trips_in_branch_event_payloads() {
let mismatch = LaneEvent::new(
LaneEventName::BranchWorkspaceMismatch,
LaneEventStatus::Blocked,
"2026-04-04T00:00:02Z",
)
.with_failure_class(LaneFailureClass::WorkspaceMismatch)
.with_detail("session belongs to /tmp/repo-a but current workspace is /tmp/repo-b")
.with_data(json!({
"expectedWorkspaceRoot": "/tmp/repo-a",
"actualWorkspaceRoot": "/tmp/repo-b",
"sessionId": "sess-123",
}));
let mismatch_json = serde_json::to_value(&mismatch).expect("lane event should serialize");
assert_eq!(mismatch_json["event"], "branch.workspace_mismatch");
assert_eq!(mismatch_json["failureClass"], "workspace_mismatch");
assert_eq!(
mismatch_json["data"]["expectedWorkspaceRoot"],
"/tmp/repo-a"
);
let round_trip: LaneEvent =
serde_json::from_value(mismatch_json).expect("lane event should deserialize");
assert_eq!(round_trip.event, LaneEventName::BranchWorkspaceMismatch);
assert_eq!(
round_trip.failure_class,
Some(LaneFailureClass::WorkspaceMismatch)
);
}
#[test]
fn commit_events_can_carry_worktree_and_supersession_metadata() {
let event = LaneEvent::commit_created(