mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
fix(runtime): document phantom completion root cause + add workspace_root to session (#41)
Global session store causes cross-worktree confusion in parallel lanes. Added workspace_root field to session metadata and documented root cause in ROADMAP.md.
This commit is contained in:
@@ -71,6 +71,13 @@ struct SessionPersistence {
|
||||
}
|
||||
|
||||
/// Persisted conversational state for the runtime and CLI session manager.
|
||||
///
|
||||
/// `workspace_root` binds the session to the worktree it was created in. The
|
||||
/// global session store under `~/.local/share/opencode` is shared across every
|
||||
/// `opencode serve` instance, so without an explicit workspace root parallel
|
||||
/// lanes can race and report success while writes land in the wrong CWD. See
|
||||
/// ROADMAP.md item 41 (Phantom completions root cause) for the full
|
||||
/// background.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub version: u32,
|
||||
@@ -80,6 +87,7 @@ pub struct Session {
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
pub compaction: Option<SessionCompaction>,
|
||||
pub fork: Option<SessionFork>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
persistence: Option<SessionPersistence>,
|
||||
}
|
||||
|
||||
@@ -92,6 +100,7 @@ impl PartialEq for Session {
|
||||
&& self.messages == other.messages
|
||||
&& self.compaction == other.compaction
|
||||
&& self.fork == other.fork
|
||||
&& self.workspace_root == other.workspace_root
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +150,7 @@ impl Session {
|
||||
messages: Vec::new(),
|
||||
compaction: None,
|
||||
fork: None,
|
||||
workspace_root: None,
|
||||
persistence: None,
|
||||
}
|
||||
}
|
||||
@@ -151,6 +161,22 @@ impl Session {
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind this session to the workspace root it was created in.
|
||||
///
|
||||
/// This is the per-worktree counterpart to the global session store and
|
||||
/// lets downstream tooling reject writes that drift to the wrong CWD when
|
||||
/// multiple `opencode serve` instances share `~/.local/share/opencode`.
|
||||
#[must_use]
|
||||
pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
|
||||
self.workspace_root = Some(workspace_root.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workspace_root(&self) -> Option<&Path> {
|
||||
self.workspace_root.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn persistence_path(&self) -> Option<&Path> {
|
||||
self.persistence.as_ref().map(|value| value.path.as_path())
|
||||
@@ -225,6 +251,7 @@ impl Session {
|
||||
parent_session_id: self.session_id.clone(),
|
||||
branch_name: normalize_optional_string(branch_name),
|
||||
}),
|
||||
workspace_root: self.workspace_root.clone(),
|
||||
persistence: None,
|
||||
}
|
||||
}
|
||||
@@ -262,6 +289,12 @@ impl Session {
|
||||
if let Some(fork) = &self.fork {
|
||||
object.insert("fork".to_string(), fork.to_json());
|
||||
}
|
||||
if let Some(workspace_root) = &self.workspace_root {
|
||||
object.insert(
|
||||
"workspace_root".to_string(),
|
||||
JsonValue::String(workspace_root_to_string(workspace_root)?),
|
||||
);
|
||||
}
|
||||
Ok(JsonValue::Object(object))
|
||||
}
|
||||
|
||||
@@ -302,6 +335,10 @@ impl Session {
|
||||
.map(SessionCompaction::from_json)
|
||||
.transpose()?;
|
||||
let fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||
let workspace_root = object
|
||||
.get("workspace_root")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(PathBuf::from);
|
||||
Ok(Self {
|
||||
version,
|
||||
session_id,
|
||||
@@ -310,6 +347,7 @@ impl Session {
|
||||
messages,
|
||||
compaction,
|
||||
fork,
|
||||
workspace_root,
|
||||
persistence: None,
|
||||
})
|
||||
}
|
||||
@@ -322,6 +360,7 @@ impl Session {
|
||||
let mut messages = Vec::new();
|
||||
let mut compaction = None;
|
||||
let mut fork = None;
|
||||
let mut workspace_root = None;
|
||||
|
||||
for (line_number, raw_line) in contents.lines().enumerate() {
|
||||
let line = raw_line.trim();
|
||||
@@ -356,6 +395,10 @@ impl Session {
|
||||
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
||||
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
||||
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||
workspace_root = object
|
||||
.get("workspace_root")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(PathBuf::from);
|
||||
}
|
||||
"message" => {
|
||||
let message_value = object.get("message").ok_or_else(|| {
|
||||
@@ -389,6 +432,7 @@ impl Session {
|
||||
messages,
|
||||
compaction,
|
||||
fork,
|
||||
workspace_root,
|
||||
persistence: None,
|
||||
})
|
||||
}
|
||||
@@ -449,6 +493,12 @@ impl Session {
|
||||
if let Some(fork) = &self.fork {
|
||||
object.insert("fork".to_string(), fork.to_json());
|
||||
}
|
||||
if let Some(workspace_root) = &self.workspace_root {
|
||||
object.insert(
|
||||
"workspace_root".to_string(),
|
||||
JsonValue::String(workspace_root_to_string(workspace_root)?),
|
||||
);
|
||||
}
|
||||
Ok(JsonValue::Object(object))
|
||||
}
|
||||
|
||||
@@ -825,6 +875,15 @@ fn i64_from_usize(value: usize, key: &str) -> Result<i64, SessionError> {
|
||||
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
|
||||
}
|
||||
|
||||
fn workspace_root_to_string(path: &Path) -> Result<String, SessionError> {
|
||||
path.to_str().map(ToOwned::to_owned).ok_or_else(|| {
|
||||
SessionError::Format(format!(
|
||||
"workspace_root is not valid UTF-8: {}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
@@ -1206,6 +1265,29 @@ mod tests {
|
||||
assert!(error.to_string().contains("unsupported block type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_workspace_root_round_trip_and_forks_inherit_it() {
|
||||
// given
|
||||
let path = temp_session_path("workspace-root");
|
||||
let workspace_root = PathBuf::from("/tmp/b4-phantom-diag");
|
||||
let mut session = Session::new().with_workspace_root(workspace_root.clone());
|
||||
session
|
||||
.push_user_text("write to the right cwd")
|
||||
.expect("user message should append");
|
||||
|
||||
// when
|
||||
session
|
||||
.save_to_path(&path)
|
||||
.expect("workspace-bound session should save");
|
||||
let restored = Session::load_from_path(&path).expect("session should load");
|
||||
let forked = restored.fork(Some("phantom-diag".to_string()));
|
||||
fs::remove_file(&path).expect("temp file should be removable");
|
||||
|
||||
// then
|
||||
assert_eq!(restored.workspace_root(), Some(workspace_root.as_path()));
|
||||
assert_eq!(forked.workspace_root(), Some(workspace_root.as_path()));
|
||||
}
|
||||
|
||||
fn temp_session_path(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
Reference in New Issue
Block a user