mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-29 02:55:07 +08:00
Compare commits
4 Commits
claw-code-
...
feat/134-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7235260c61 | ||
|
|
230d97a8fa | ||
|
|
2b7095e4ae | ||
|
|
f55612ea47 |
@@ -244,6 +244,7 @@ pub struct LaneEventBuilder {
|
|||||||
event: LaneEventName,
|
event: LaneEventName,
|
||||||
status: LaneEventStatus,
|
status: LaneEventStatus,
|
||||||
emitted_at: String,
|
emitted_at: String,
|
||||||
|
session_id: Option<String>,
|
||||||
metadata: LaneEventMetadata,
|
metadata: LaneEventMetadata,
|
||||||
detail: Option<String>,
|
detail: Option<String>,
|
||||||
failure_class: Option<LaneFailureClass>,
|
failure_class: Option<LaneFailureClass>,
|
||||||
@@ -264,6 +265,7 @@ impl LaneEventBuilder {
|
|||||||
event,
|
event,
|
||||||
status,
|
status,
|
||||||
emitted_at: emitted_at.into(),
|
emitted_at: emitted_at.into(),
|
||||||
|
session_id: None,
|
||||||
metadata: LaneEventMetadata::new(seq, provenance),
|
metadata: LaneEventMetadata::new(seq, provenance),
|
||||||
detail: None,
|
detail: None,
|
||||||
failure_class: None,
|
failure_class: None,
|
||||||
@@ -278,6 +280,13 @@ impl LaneEventBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add boot-scoped session correlation id
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||||
|
self.session_id = Some(session_id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Add ownership info
|
/// Add ownership info
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
|
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
|
||||||
@@ -328,6 +337,7 @@ impl LaneEventBuilder {
|
|||||||
event: self.event,
|
event: self.event,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
emitted_at: self.emitted_at,
|
emitted_at: self.emitted_at,
|
||||||
|
session_id: self.session_id,
|
||||||
failure_class: self.failure_class,
|
failure_class: self.failure_class,
|
||||||
detail: self.detail,
|
detail: self.detail,
|
||||||
data: self.data,
|
data: self.data,
|
||||||
@@ -405,7 +415,10 @@ pub enum BlockedSubphase {
|
|||||||
#[serde(rename = "blocked.branch_freshness")]
|
#[serde(rename = "blocked.branch_freshness")]
|
||||||
BranchFreshness { behind_main: u32 },
|
BranchFreshness { behind_main: u32 },
|
||||||
#[serde(rename = "blocked.test_hang")]
|
#[serde(rename = "blocked.test_hang")]
|
||||||
TestHang { elapsed_secs: u32, test_name: Option<String> },
|
TestHang {
|
||||||
|
elapsed_secs: u32,
|
||||||
|
test_name: Option<String>,
|
||||||
|
},
|
||||||
#[serde(rename = "blocked.report_pending")]
|
#[serde(rename = "blocked.report_pending")]
|
||||||
ReportPending { since_secs: u32 },
|
ReportPending { since_secs: u32 },
|
||||||
}
|
}
|
||||||
@@ -462,6 +475,8 @@ pub struct LaneEvent {
|
|||||||
pub status: LaneEventStatus,
|
pub status: LaneEventStatus,
|
||||||
#[serde(rename = "emittedAt")]
|
#[serde(rename = "emittedAt")]
|
||||||
pub emitted_at: String,
|
pub emitted_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
|
||||||
pub failure_class: Option<LaneFailureClass>,
|
pub failure_class: Option<LaneFailureClass>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -485,6 +500,7 @@ impl LaneEvent {
|
|||||||
event,
|
event,
|
||||||
status,
|
status,
|
||||||
emitted_at: emitted_at.into(),
|
emitted_at: emitted_at.into(),
|
||||||
|
session_id: None,
|
||||||
failure_class: None,
|
failure_class: None,
|
||||||
detail: None,
|
detail: None,
|
||||||
data: None,
|
data: None,
|
||||||
@@ -543,7 +559,8 @@ impl LaneEvent {
|
|||||||
.with_failure_class(blocker.failure_class)
|
.with_failure_class(blocker.failure_class)
|
||||||
.with_detail(blocker.detail.clone());
|
.with_detail(blocker.detail.clone());
|
||||||
if let Some(ref subphase) = blocker.subphase {
|
if let Some(ref subphase) = blocker.subphase {
|
||||||
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
event =
|
||||||
|
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||||
}
|
}
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
@@ -554,7 +571,8 @@ impl LaneEvent {
|
|||||||
.with_failure_class(blocker.failure_class)
|
.with_failure_class(blocker.failure_class)
|
||||||
.with_detail(blocker.detail.clone());
|
.with_detail(blocker.detail.clone());
|
||||||
if let Some(ref subphase) = blocker.subphase {
|
if let Some(ref subphase) = blocker.subphase {
|
||||||
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
event =
|
||||||
|
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||||
}
|
}
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
@@ -562,8 +580,12 @@ impl LaneEvent {
|
|||||||
/// Ship prepared — §4.44.5
|
/// Ship prepared — §4.44.5
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||||
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
|
Self::new(
|
||||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
LaneEventName::ShipPrepared,
|
||||||
|
LaneEventStatus::Ready,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship commits selected — §4.44.5
|
/// Ship commits selected — §4.44.5
|
||||||
@@ -573,22 +595,34 @@ impl LaneEvent {
|
|||||||
commit_count: u32,
|
commit_count: u32,
|
||||||
commit_range: impl Into<String>,
|
commit_range: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
|
Self::new(
|
||||||
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
LaneEventName::ShipCommitsSelected,
|
||||||
|
LaneEventStatus::Ready,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship merged — §4.44.5
|
/// Ship merged — §4.44.5
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||||
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
|
Self::new(
|
||||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
LaneEventName::ShipMerged,
|
||||||
|
LaneEventStatus::Completed,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship pushed to main — §4.44.5
|
/// Ship pushed to main — §4.44.5
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||||
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
|
Self::new(
|
||||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
LaneEventName::ShipPushedMain,
|
||||||
|
LaneEventStatus::Completed,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -614,6 +648,12 @@ impl LaneEvent {
|
|||||||
self.data = Some(data);
|
self.data = Some(data);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||||
|
self.session_id = Some(session_id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -1044,6 +1084,7 @@ mod tests {
|
|||||||
42,
|
42,
|
||||||
EventProvenance::Test,
|
EventProvenance::Test,
|
||||||
)
|
)
|
||||||
|
.with_session_id("boot-abc123def4567890")
|
||||||
.with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test"))
|
.with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test"))
|
||||||
.with_ownership(LaneOwnership {
|
.with_ownership(LaneOwnership {
|
||||||
owner: "bot-1".to_string(),
|
owner: "bot-1".to_string(),
|
||||||
@@ -1055,6 +1096,7 @@ mod tests {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
assert_eq!(event.event, LaneEventName::Started);
|
assert_eq!(event.event, LaneEventName::Started);
|
||||||
|
assert_eq!(event.session_id.as_deref(), Some("boot-abc123def4567890"));
|
||||||
assert_eq!(event.metadata.seq, 42);
|
assert_eq!(event.metadata.seq, 42);
|
||||||
assert_eq!(event.metadata.provenance, EventProvenance::Test);
|
assert_eq!(event.metadata.provenance, EventProvenance::Test);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1084,4 +1126,34 @@ mod tests {
|
|||||||
assert_eq!(round_trip.provenance, EventProvenance::Healthcheck);
|
assert_eq!(round_trip.provenance, EventProvenance::Healthcheck);
|
||||||
assert_eq!(round_trip.nudge_id, Some("nudge-abc".to_string()));
|
assert_eq!(round_trip.nudge_id, Some("nudge-abc".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lane_event_session_id_round_trips_through_serialization() {
|
||||||
|
let event = LaneEventBuilder::new(
|
||||||
|
LaneEventName::Started,
|
||||||
|
LaneEventStatus::Running,
|
||||||
|
"2026-04-04T00:00:00Z",
|
||||||
|
1,
|
||||||
|
EventProvenance::LiveLane,
|
||||||
|
)
|
||||||
|
.with_session_id("boot-0123456789abcdef")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&event).expect("should serialize");
|
||||||
|
assert_eq!(json["session_id"], "boot-0123456789abcdef");
|
||||||
|
|
||||||
|
let round_trip: LaneEvent = serde_json::from_value(json).expect("should deserialize");
|
||||||
|
assert_eq!(
|
||||||
|
round_trip.session_id.as_deref(),
|
||||||
|
Some("boot-0123456789abcdef")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lane_event_session_id_omits_field_when_absent() {
|
||||||
|
let event = LaneEvent::started("2026-04-04T00:00:00Z");
|
||||||
|
let json = serde_json::to_value(&event).expect("should serialize");
|
||||||
|
|
||||||
|
assert!(json.get("session_id").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ mod remote;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
mod session;
|
mod session;
|
||||||
pub mod session_control;
|
pub mod session_control;
|
||||||
|
mod session_identity;
|
||||||
pub use session_control::SessionStore;
|
pub use session_control::SessionStore;
|
||||||
mod sse;
|
mod sse;
|
||||||
pub mod stale_base;
|
pub mod stale_base;
|
||||||
@@ -153,6 +154,9 @@ pub use session::{
|
|||||||
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
||||||
SessionFork, SessionPromptEntry,
|
SessionFork, SessionPromptEntry,
|
||||||
};
|
};
|
||||||
|
pub use session_identity::{
|
||||||
|
begin_session, current_boot_session_id, end_session, is_active_session,
|
||||||
|
};
|
||||||
pub use sse::{IncrementalSseParser, SseEvent};
|
pub use sse::{IncrementalSseParser, SseEvent};
|
||||||
pub use stale_base::{
|
pub use stale_base::{
|
||||||
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,
|
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,
|
||||||
|
|||||||
84
rust/crates/runtime/src/session_identity.rs
Normal file
84
rust/crates/runtime/src/session_identity.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::env;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::process;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static BOOT_SESSION_ID: OnceLock<String> = OnceLock::new();
|
||||||
|
static BOOT_SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static ACTIVE_SESSION: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn current_boot_session_id() -> &'static str {
|
||||||
|
BOOT_SESSION_ID.get_or_init(resolve_boot_session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_session() {
|
||||||
|
ACTIVE_SESSION.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_session() {
|
||||||
|
ACTIVE_SESSION.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_active_session() -> bool {
|
||||||
|
ACTIVE_SESSION.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_boot_session_id() -> String {
|
||||||
|
match env::var("CLAW_SESSION_ID") {
|
||||||
|
Ok(value) if !value.trim().is_empty() => value,
|
||||||
|
_ => generate_boot_session_id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_boot_session_id() -> String {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
let counter = BOOT_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
process::id().hash(&mut hasher);
|
||||||
|
nanos.hash(&mut hasher);
|
||||||
|
counter.hash(&mut hasher);
|
||||||
|
format!("boot-{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{begin_session, current_boot_session_id, end_session, is_active_session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_current_boot_session_id_when_called_twice_then_it_is_stable() {
|
||||||
|
let first = current_boot_session_id();
|
||||||
|
let second = current_boot_session_id();
|
||||||
|
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert!(first.starts_with("boot-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_current_boot_session_id_when_inspected_then_it_is_opaque_and_non_empty() {
|
||||||
|
let session_id = current_boot_session_id();
|
||||||
|
|
||||||
|
assert!(!session_id.trim().is_empty());
|
||||||
|
assert_eq!(session_id.len(), 21);
|
||||||
|
assert!(!session_id.contains(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_begin_and_end_session_when_checked_then_active_state_toggles() {
|
||||||
|
end_session();
|
||||||
|
assert!(!is_active_session());
|
||||||
|
|
||||||
|
begin_session();
|
||||||
|
assert!(is_active_session());
|
||||||
|
|
||||||
|
end_session();
|
||||||
|
assert!(!is_active_session());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::current_boot_session_id;
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
fn now_secs() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -768,6 +770,7 @@ fn push_event(
|
|||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct StateSnapshot<'a> {
|
struct StateSnapshot<'a> {
|
||||||
worker_id: &'a str,
|
worker_id: &'a str,
|
||||||
|
session_id: &'a str,
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
is_ready: bool,
|
is_ready: bool,
|
||||||
trust_gate_cleared: bool,
|
trust_gate_cleared: bool,
|
||||||
@@ -790,6 +793,7 @@ fn emit_state_file(worker: &Worker) {
|
|||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let snapshot = StateSnapshot {
|
let snapshot = StateSnapshot {
|
||||||
worker_id: &worker.worker_id,
|
worker_id: &worker.worker_id,
|
||||||
|
session_id: current_boot_session_id(),
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
is_ready: worker.status == WorkerStatus::ReadyForPrompt,
|
is_ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||||
trust_gate_cleared: worker.trust_gate_cleared,
|
trust_gate_cleared: worker.trust_gate_cleared,
|
||||||
@@ -1449,6 +1453,10 @@ mod tests {
|
|||||||
Some("spawning"),
|
Some("spawning"),
|
||||||
"initial status should be spawning"
|
"initial status should be spawning"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value["session_id"].as_str(),
|
||||||
|
Some(current_boot_session_id())
|
||||||
|
);
|
||||||
assert_eq!(value["is_ready"].as_bool(), Some(false));
|
assert_eq!(value["is_ready"].as_bool(), Some(false));
|
||||||
|
|
||||||
// Transition to ReadyForPrompt by observing trust-cleared text
|
// Transition to ReadyForPrompt by observing trust-cleared text
|
||||||
|
|||||||
@@ -1556,6 +1556,8 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
|||||||
project_root,
|
project_root,
|
||||||
git_branch,
|
git_branch,
|
||||||
git_summary,
|
git_summary,
|
||||||
|
active_session: false,
|
||||||
|
session_id: None,
|
||||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||||
};
|
};
|
||||||
Ok(DoctorReport {
|
Ok(DoctorReport {
|
||||||
@@ -2376,6 +2378,8 @@ struct ResumeCommandOutcome {
|
|||||||
struct StatusContext {
|
struct StatusContext {
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
session_path: Option<PathBuf>,
|
session_path: Option<PathBuf>,
|
||||||
|
active_session: bool,
|
||||||
|
session_id: Option<String>,
|
||||||
loaded_config_files: usize,
|
loaded_config_files: usize,
|
||||||
discovered_config_files: usize,
|
discovered_config_files: usize,
|
||||||
memory_file_count: usize,
|
memory_file_count: usize,
|
||||||
@@ -2385,6 +2389,16 @@ struct StatusContext {
|
|||||||
sandbox_status: runtime::SandboxStatus,
|
sandbox_status: runtime::SandboxStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct WorkerStateSnapshot {
|
||||||
|
#[serde(default)]
|
||||||
|
status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_in_flight: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct StatusUsage {
|
struct StatusUsage {
|
||||||
message_count: usize,
|
message_count: usize,
|
||||||
@@ -4993,6 +5007,8 @@ fn status_json_value(
|
|||||||
"kind": "status",
|
"kind": "status",
|
||||||
"model": model,
|
"model": model,
|
||||||
"permission_mode": permission_mode,
|
"permission_mode": permission_mode,
|
||||||
|
"active_session": context.active_session,
|
||||||
|
"session_id": context.session_id,
|
||||||
"usage": {
|
"usage": {
|
||||||
"messages": usage.message_count,
|
"messages": usage.message_count,
|
||||||
"turns": usage.turns,
|
"turns": usage.turns,
|
||||||
@@ -5051,9 +5067,12 @@ fn status_context(
|
|||||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||||
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||||
|
let worker_state = read_worker_state_snapshot(&cwd);
|
||||||
Ok(StatusContext {
|
Ok(StatusContext {
|
||||||
cwd,
|
cwd,
|
||||||
session_path: session_path.map(Path::to_path_buf),
|
session_path: session_path.map(Path::to_path_buf),
|
||||||
|
active_session: worker_state.as_ref().is_some_and(worker_state_is_active),
|
||||||
|
session_id: worker_state.and_then(|snapshot| snapshot.session_id),
|
||||||
loaded_config_files: runtime_config.loaded_entries().len(),
|
loaded_config_files: runtime_config.loaded_entries().len(),
|
||||||
discovered_config_files,
|
discovered_config_files,
|
||||||
memory_file_count: project_context.instruction_files.len(),
|
memory_file_count: project_context.instruction_files.len(),
|
||||||
@@ -5064,6 +5083,20 @@ fn status_context(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_worker_state_snapshot(cwd: &Path) -> Option<WorkerStateSnapshot> {
|
||||||
|
let state_path = cwd.join(".claw").join("worker-state.json");
|
||||||
|
let raw = fs::read_to_string(state_path).ok()?;
|
||||||
|
serde_json::from_str(&raw).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_state_is_active(snapshot: &WorkerStateSnapshot) -> bool {
|
||||||
|
snapshot.prompt_in_flight
|
||||||
|
|| matches!(
|
||||||
|
snapshot.status.as_deref(),
|
||||||
|
Some("spawning" | "trust_required" | "ready_for_prompt" | "running")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn format_status_report(
|
fn format_status_report(
|
||||||
model: &str,
|
model: &str,
|
||||||
usage: StatusUsage,
|
usage: StatusUsage,
|
||||||
@@ -5117,7 +5150,7 @@ fn format_status_report(
|
|||||||
context.git_summary.unstaged_files,
|
context.git_summary.unstaged_files,
|
||||||
context.git_summary.untracked_files,
|
context.git_summary.untracked_files,
|
||||||
context.session_path.as_ref().map_or_else(
|
context.session_path.as_ref().map_or_else(
|
||||||
|| "live-repl".to_string(),
|
|| format_active_session(context),
|
||||||
|path| path.display().to_string()
|
|path| path.display().to_string()
|
||||||
),
|
),
|
||||||
context.loaded_config_files,
|
context.loaded_config_files,
|
||||||
@@ -5133,6 +5166,17 @@ fn format_status_report(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_active_session(context: &StatusContext) -> String {
|
||||||
|
if context.active_session {
|
||||||
|
match context.session_id.as_deref() {
|
||||||
|
Some(session_id) => format!("active ({session_id})"),
|
||||||
|
None => "active".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"idle".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Sandbox
|
"Sandbox
|
||||||
@@ -8946,7 +8990,7 @@ mod tests {
|
|||||||
let args = vec![
|
let args = vec![
|
||||||
"--output-format=json".to_string(),
|
"--output-format=json".to_string(),
|
||||||
"--model".to_string(),
|
"--model".to_string(),
|
||||||
"claude-opus".to_string(),
|
"opus".to_string(),
|
||||||
"explain".to_string(),
|
"explain".to_string(),
|
||||||
"this".to_string(),
|
"this".to_string(),
|
||||||
];
|
];
|
||||||
@@ -8954,7 +8998,7 @@ mod tests {
|
|||||||
parse_args(&args).expect("args should parse"),
|
parse_args(&args).expect("args should parse"),
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt: "explain this".to_string(),
|
prompt: "explain this".to_string(),
|
||||||
model: "claude-opus".to_string(),
|
model: "claude-opus-4-6".to_string(),
|
||||||
output_format: CliOutputFormat::Json,
|
output_format: CliOutputFormat::Json,
|
||||||
allowed_tools: None,
|
allowed_tools: None,
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
permission_mode: PermissionMode::DangerFullAccess,
|
||||||
@@ -9724,15 +9768,21 @@ mod tests {
|
|||||||
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||||
// Input is ["help", "me", "debug"] so the joined prompt shorthand
|
// Input is ["--model", "opus", "please", "debug", "this"] so the joined
|
||||||
// must be "help me debug". A previous batch accidentally rewrote
|
// prompt shorthand must stay a normal multi-word prompt while still
|
||||||
// the expected string to "$help overview" (copy-paste slip).
|
// honoring alias validation at parse time.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
|
parse_args(&[
|
||||||
.expect("prompt shorthand should still work"),
|
"--model".to_string(),
|
||||||
|
"opus".to_string(),
|
||||||
|
"please".to_string(),
|
||||||
|
"debug".to_string(),
|
||||||
|
"this".to_string(),
|
||||||
|
])
|
||||||
|
.expect("prompt shorthand should still work"),
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt: "help me debug".to_string(),
|
prompt: "please debug this".to_string(),
|
||||||
model: DEFAULT_MODEL.to_string(),
|
model: "claude-opus-4-6".to_string(),
|
||||||
output_format: CliOutputFormat::Text,
|
output_format: CliOutputFormat::Text,
|
||||||
allowed_tools: None,
|
allowed_tools: None,
|
||||||
permission_mode: crate::default_permission_mode(),
|
permission_mode: crate::default_permission_mode(),
|
||||||
@@ -10346,6 +10396,8 @@ mod tests {
|
|||||||
&super::StatusContext {
|
&super::StatusContext {
|
||||||
cwd: PathBuf::from("/tmp/project"),
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
session_path: Some(PathBuf::from("session.jsonl")),
|
session_path: Some(PathBuf::from("session.jsonl")),
|
||||||
|
active_session: true,
|
||||||
|
session_id: Some("boot-status-test".to_string()),
|
||||||
loaded_config_files: 2,
|
loaded_config_files: 2,
|
||||||
discovered_config_files: 3,
|
discovered_config_files: 3,
|
||||||
memory_file_count: 4,
|
memory_file_count: 4,
|
||||||
@@ -10374,10 +10426,10 @@ mod tests {
|
|||||||
status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked")
|
status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked")
|
||||||
);
|
);
|
||||||
assert!(status.contains("Changed files 3"));
|
assert!(status.contains("Changed files 3"));
|
||||||
|
assert!(status.contains("Session session.jsonl"));
|
||||||
assert!(status.contains("Staged 1"));
|
assert!(status.contains("Staged 1"));
|
||||||
assert!(status.contains("Unstaged 1"));
|
assert!(status.contains("Unstaged 1"));
|
||||||
assert!(status.contains("Untracked 1"));
|
assert!(status.contains("Untracked 1"));
|
||||||
assert!(status.contains("Session session.jsonl"));
|
|
||||||
assert!(status.contains("Config files loaded 2/3"));
|
assert!(status.contains("Config files loaded 2/3"));
|
||||||
assert!(status.contains("Memory files 4"));
|
assert!(status.contains("Memory files 4"));
|
||||||
assert!(status.contains("Suggested flow /status → /diff → /commit"));
|
assert!(status.contains("Suggested flow /status → /diff → /commit"));
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ fn status_and_sandbox_emit_json_when_requested() {
|
|||||||
|
|
||||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||||
assert_eq!(status["kind"], "status");
|
assert_eq!(status["kind"], "status");
|
||||||
|
assert_eq!(status["active_session"], false);
|
||||||
|
assert!(status["session_id"].is_null());
|
||||||
assert!(status["workspace"]["cwd"].as_str().is_some());
|
assert!(status["workspace"]["cwd"].as_str().is_some());
|
||||||
|
|
||||||
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
|
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
|
||||||
@@ -384,6 +386,47 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
|||||||
assert!(root.join("CLAUDE.md").exists());
|
assert!(root.join("CLAUDE.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_json_surfaces_active_session_and_boot_session_id_from_worker_state() {
|
||||||
|
let root = unique_temp_dir("status-worker-state-json");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
write_worker_state_fixture(&root, "running", "boot-fixture-123");
|
||||||
|
|
||||||
|
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||||
|
assert_eq!(status["kind"], "status");
|
||||||
|
assert_eq!(status["active_session"], true);
|
||||||
|
assert_eq!(status["session_id"], "boot-fixture-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_text_surfaces_active_session_and_boot_session_id_from_worker_state() {
|
||||||
|
let root = unique_temp_dir("status-worker-state-text");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
write_worker_state_fixture(&root, "running", "boot-fixture-456");
|
||||||
|
|
||||||
|
let output = run_claw(&root, &["status"], &[]);
|
||||||
|
assert!(output.status.success());
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("Session active (boot-fixture-456)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_state_fixture_round_trips_session_id_across_status_surface() {
|
||||||
|
let root = unique_temp_dir("status-worker-state-roundtrip");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
let session_id = "boot-roundtrip-789";
|
||||||
|
write_worker_state_fixture(&root, "running", session_id);
|
||||||
|
|
||||||
|
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||||
|
assert_eq!(status["active_session"], true);
|
||||||
|
assert_eq!(status["session_id"], session_id);
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(root.join(".claw").join("worker-state.json"))
|
||||||
|
.expect("worker state should exist");
|
||||||
|
let state: Value = serde_json::from_str(&raw).expect("worker state should be valid json");
|
||||||
|
assert_eq!(state["session_id"], session_id);
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||||
assert_json_command_with_env(current_dir, args, &[])
|
assert_json_command_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
@@ -431,6 +474,26 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
|||||||
upstream
|
upstream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_worker_state_fixture(root: &Path, status: &str, session_id: &str) {
|
||||||
|
let claw_dir = root.join(".claw");
|
||||||
|
fs::create_dir_all(&claw_dir).expect("worker state dir should exist");
|
||||||
|
fs::write(
|
||||||
|
claw_dir.join("worker-state.json"),
|
||||||
|
serde_json::to_string_pretty(&serde_json::json!({
|
||||||
|
"worker_id": "worker-test",
|
||||||
|
"session_id": session_id,
|
||||||
|
"status": status,
|
||||||
|
"is_ready": status == "ready_for_prompt",
|
||||||
|
"trust_gate_cleared": false,
|
||||||
|
"prompt_in_flight": status == "running",
|
||||||
|
"updated_at": 1,
|
||||||
|
"seconds_since_update": 0
|
||||||
|
}))
|
||||||
|
.expect("worker state json should serialize"),
|
||||||
|
)
|
||||||
|
.expect("worker state fixture should write");
|
||||||
|
}
|
||||||
|
|
||||||
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
|
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
|
||||||
let session_path = root.join("session.jsonl");
|
let session_path = root.join("session.jsonl");
|
||||||
let mut session = Session::new()
|
let mut session = Session::new()
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use api::{
|
|||||||
use plugins::PluginTool;
|
use plugins::PluginTool;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
|
check_freshness, current_boot_session_id, dedupe_superseded_commit_events, edit_file,
|
||||||
grep_search, load_system_prompt,
|
execute_bash, glob_search, grep_search, load_system_prompt,
|
||||||
lsp_client::LspRegistry,
|
lsp_client::LspRegistry,
|
||||||
mcp_tool_bridge::McpToolRegistry,
|
mcp_tool_bridge::McpToolRegistry,
|
||||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||||
@@ -3535,7 +3535,9 @@ where
|
|||||||
created_at: created_at.clone(),
|
created_at: created_at.clone(),
|
||||||
started_at: Some(created_at),
|
started_at: Some(created_at),
|
||||||
completed_at: None,
|
completed_at: None,
|
||||||
lane_events: vec![LaneEvent::started(iso8601_now())],
|
lane_events: vec![
|
||||||
|
LaneEvent::started(iso8601_now()).with_session_id(current_boot_session_id())
|
||||||
|
],
|
||||||
current_blocker: None,
|
current_blocker: None,
|
||||||
derived_state: String::from("working"),
|
derived_state: String::from("working"),
|
||||||
error: None,
|
error: None,
|
||||||
@@ -3744,6 +3746,11 @@ fn persist_agent_terminal_state(
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let blocker = error.as_deref().map(classify_lane_blocker);
|
let blocker = error.as_deref().map(classify_lane_blocker);
|
||||||
|
let session_id = manifest
|
||||||
|
.lane_events
|
||||||
|
.last()
|
||||||
|
.and_then(|event| event.session_id.clone())
|
||||||
|
.unwrap_or_else(|| current_boot_session_id().to_string());
|
||||||
append_agent_output(
|
append_agent_output(
|
||||||
&manifest.output_file,
|
&manifest.output_file,
|
||||||
&format_agent_terminal_output(status, result, blocker.as_ref(), error.as_deref()),
|
&format_agent_terminal_output(status, result, blocker.as_ref(), error.as_deref()),
|
||||||
@@ -3758,26 +3765,31 @@ fn persist_agent_terminal_state(
|
|||||||
if let Some(blocker) = blocker {
|
if let Some(blocker) = blocker {
|
||||||
next_manifest
|
next_manifest
|
||||||
.lane_events
|
.lane_events
|
||||||
.push(LaneEvent::blocked(iso8601_now(), &blocker));
|
.push(LaneEvent::blocked(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||||
next_manifest
|
next_manifest
|
||||||
.lane_events
|
.lane_events
|
||||||
.push(LaneEvent::failed(iso8601_now(), &blocker));
|
.push(LaneEvent::failed(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||||
} else {
|
} else {
|
||||||
next_manifest.current_blocker = None;
|
next_manifest.current_blocker = None;
|
||||||
let mut finished_summary = build_lane_finished_summary(&next_manifest, result);
|
let mut finished_summary = build_lane_finished_summary(&next_manifest, result);
|
||||||
finished_summary.data.disabled_cron_ids = disable_matching_crons(&next_manifest, result);
|
finished_summary.data.disabled_cron_ids = disable_matching_crons(&next_manifest, result);
|
||||||
next_manifest.lane_events.push(
|
next_manifest.lane_events.push(
|
||||||
LaneEvent::finished(iso8601_now(), finished_summary.detail).with_data(
|
LaneEvent::finished(iso8601_now(), finished_summary.detail)
|
||||||
serde_json::to_value(&finished_summary.data)
|
.with_data(
|
||||||
.expect("lane summary metadata should serialize"),
|
serde_json::to_value(&finished_summary.data)
|
||||||
),
|
.expect("lane summary metadata should serialize"),
|
||||||
|
)
|
||||||
|
.with_session_id(session_id.clone()),
|
||||||
);
|
);
|
||||||
if let Some(provenance) = maybe_commit_provenance(result) {
|
if let Some(provenance) = maybe_commit_provenance(result) {
|
||||||
next_manifest.lane_events.push(LaneEvent::commit_created(
|
next_manifest.lane_events.push(
|
||||||
iso8601_now(),
|
LaneEvent::commit_created(
|
||||||
Some(format!("commit {}", provenance.commit)),
|
iso8601_now(),
|
||||||
provenance,
|
Some(format!("commit {}", provenance.commit)),
|
||||||
));
|
provenance,
|
||||||
|
)
|
||||||
|
.with_session_id(session_id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
write_agent_manifest(&next_manifest)
|
write_agent_manifest(&next_manifest)
|
||||||
@@ -7761,6 +7773,9 @@ mod tests {
|
|||||||
assert!(manifest_contents.contains("\"status\": \"running\""));
|
assert!(manifest_contents.contains("\"status\": \"running\""));
|
||||||
assert_eq!(manifest_json["laneEvents"][0]["event"], "lane.started");
|
assert_eq!(manifest_json["laneEvents"][0]["event"], "lane.started");
|
||||||
assert_eq!(manifest_json["laneEvents"][0]["status"], "running");
|
assert_eq!(manifest_json["laneEvents"][0]["status"], "running");
|
||||||
|
assert!(manifest_json["laneEvents"][0]["session_id"]
|
||||||
|
.as_str()
|
||||||
|
.is_some());
|
||||||
assert!(manifest_json["currentBlocker"].is_null());
|
assert!(manifest_json["currentBlocker"].is_null());
|
||||||
let captured_job = captured
|
let captured_job = captured
|
||||||
.lock()
|
.lock()
|
||||||
@@ -7838,10 +7853,17 @@ mod tests {
|
|||||||
completed_manifest_json["laneEvents"][0]["event"],
|
completed_manifest_json["laneEvents"][0]["event"],
|
||||||
"lane.started"
|
"lane.started"
|
||||||
);
|
);
|
||||||
|
let session_id = completed_manifest_json["laneEvents"][0]["session_id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("startup session_id should exist");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
completed_manifest_json["laneEvents"][1]["event"],
|
completed_manifest_json["laneEvents"][1]["event"],
|
||||||
"lane.finished"
|
"lane.finished"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
completed_manifest_json["laneEvents"][1]["session_id"],
|
||||||
|
session_id
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
completed_manifest_json["laneEvents"][1]["data"]["qualityFloorApplied"],
|
completed_manifest_json["laneEvents"][1]["data"]["qualityFloorApplied"],
|
||||||
false
|
false
|
||||||
@@ -7854,6 +7876,10 @@ mod tests {
|
|||||||
completed_manifest_json["laneEvents"][2]["event"],
|
completed_manifest_json["laneEvents"][2]["event"],
|
||||||
"lane.commit.created"
|
"lane.commit.created"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
completed_manifest_json["laneEvents"][2]["session_id"],
|
||||||
|
session_id
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
completed_manifest_json["laneEvents"][2]["data"]["commit"],
|
completed_manifest_json["laneEvents"][2]["data"]["commit"],
|
||||||
"abc1234"
|
"abc1234"
|
||||||
|
|||||||
Reference in New Issue
Block a user