mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-26 09:44:59 +08:00
Compare commits
4 Commits
feat/jobdo
...
feat/134-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7235260c61 | ||
|
|
230d97a8fa | ||
|
|
2b7095e4ae | ||
|
|
f55612ea47 |
@@ -244,6 +244,7 @@ pub struct LaneEventBuilder {
|
||||
event: LaneEventName,
|
||||
status: LaneEventStatus,
|
||||
emitted_at: String,
|
||||
session_id: Option<String>,
|
||||
metadata: LaneEventMetadata,
|
||||
detail: Option<String>,
|
||||
failure_class: Option<LaneFailureClass>,
|
||||
@@ -264,6 +265,7 @@ impl LaneEventBuilder {
|
||||
event,
|
||||
status,
|
||||
emitted_at: emitted_at.into(),
|
||||
session_id: None,
|
||||
metadata: LaneEventMetadata::new(seq, provenance),
|
||||
detail: None,
|
||||
failure_class: None,
|
||||
@@ -278,6 +280,13 @@ impl LaneEventBuilder {
|
||||
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
|
||||
#[must_use]
|
||||
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
|
||||
@@ -328,6 +337,7 @@ impl LaneEventBuilder {
|
||||
event: self.event,
|
||||
status: self.status,
|
||||
emitted_at: self.emitted_at,
|
||||
session_id: self.session_id,
|
||||
failure_class: self.failure_class,
|
||||
detail: self.detail,
|
||||
data: self.data,
|
||||
@@ -405,7 +415,10 @@ pub enum BlockedSubphase {
|
||||
#[serde(rename = "blocked.branch_freshness")]
|
||||
BranchFreshness { behind_main: u32 },
|
||||
#[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")]
|
||||
ReportPending { since_secs: u32 },
|
||||
}
|
||||
@@ -462,6 +475,8 @@ pub struct LaneEvent {
|
||||
pub status: LaneEventStatus,
|
||||
#[serde(rename = "emittedAt")]
|
||||
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")]
|
||||
pub failure_class: Option<LaneFailureClass>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -485,6 +500,7 @@ impl LaneEvent {
|
||||
event,
|
||||
status,
|
||||
emitted_at: emitted_at.into(),
|
||||
session_id: None,
|
||||
failure_class: None,
|
||||
detail: None,
|
||||
data: None,
|
||||
@@ -543,7 +559,8 @@ impl LaneEvent {
|
||||
.with_failure_class(blocker.failure_class)
|
||||
.with_detail(blocker.detail.clone());
|
||||
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
|
||||
}
|
||||
@@ -554,7 +571,8 @@ impl LaneEvent {
|
||||
.with_failure_class(blocker.failure_class)
|
||||
.with_detail(blocker.detail.clone());
|
||||
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
|
||||
}
|
||||
@@ -562,8 +580,12 @@ impl LaneEvent {
|
||||
/// Ship prepared — §4.44.5
|
||||
#[must_use]
|
||||
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
Self::new(
|
||||
LaneEventName::ShipPrepared,
|
||||
LaneEventStatus::Ready,
|
||||
emitted_at,
|
||||
)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
}
|
||||
|
||||
/// Ship commits selected — §4.44.5
|
||||
@@ -573,22 +595,34 @@ impl LaneEvent {
|
||||
commit_count: u32,
|
||||
commit_range: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
|
||||
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
||||
Self::new(
|
||||
LaneEventName::ShipCommitsSelected,
|
||||
LaneEventStatus::Ready,
|
||||
emitted_at,
|
||||
)
|
||||
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
||||
}
|
||||
|
||||
/// Ship merged — §4.44.5
|
||||
#[must_use]
|
||||
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
Self::new(
|
||||
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
|
||||
#[must_use]
|
||||
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
Self::new(
|
||||
LaneEventName::ShipPushedMain,
|
||||
LaneEventStatus::Completed,
|
||||
emitted_at,
|
||||
)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -614,6 +648,12 @@ impl LaneEvent {
|
||||
self.data = Some(data);
|
||||
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]
|
||||
@@ -1044,6 +1084,7 @@ mod tests {
|
||||
42,
|
||||
EventProvenance::Test,
|
||||
)
|
||||
.with_session_id("boot-abc123def4567890")
|
||||
.with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test"))
|
||||
.with_ownership(LaneOwnership {
|
||||
owner: "bot-1".to_string(),
|
||||
@@ -1055,6 +1096,7 @@ mod tests {
|
||||
.build();
|
||||
|
||||
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.provenance, EventProvenance::Test);
|
||||
assert_eq!(
|
||||
@@ -1084,4 +1126,34 @@ mod tests {
|
||||
assert_eq!(round_trip.provenance, EventProvenance::Healthcheck);
|
||||
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;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
mod session_identity;
|
||||
pub use session_control::SessionStore;
|
||||
mod sse;
|
||||
pub mod stale_base;
|
||||
@@ -153,6 +154,9 @@ pub use session::{
|
||||
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
||||
SessionFork, SessionPromptEntry,
|
||||
};
|
||||
pub use session_identity::{
|
||||
begin_session, current_boot_session_id, end_session, is_active_session,
|
||||
};
|
||||
pub use sse::{IncrementalSseParser, SseEvent};
|
||||
pub use stale_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 crate::current_boot_session_id;
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -768,6 +770,7 @@ fn push_event(
|
||||
#[derive(serde::Serialize)]
|
||||
struct StateSnapshot<'a> {
|
||||
worker_id: &'a str,
|
||||
session_id: &'a str,
|
||||
status: WorkerStatus,
|
||||
is_ready: bool,
|
||||
trust_gate_cleared: bool,
|
||||
@@ -790,6 +793,7 @@ fn emit_state_file(worker: &Worker) {
|
||||
let now = now_secs();
|
||||
let snapshot = StateSnapshot {
|
||||
worker_id: &worker.worker_id,
|
||||
session_id: current_boot_session_id(),
|
||||
status: worker.status,
|
||||
is_ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||
trust_gate_cleared: worker.trust_gate_cleared,
|
||||
@@ -1449,6 +1453,10 @@ mod tests {
|
||||
Some("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));
|
||||
|
||||
// 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,
|
||||
git_branch,
|
||||
git_summary,
|
||||
active_session: false,
|
||||
session_id: None,
|
||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||
};
|
||||
Ok(DoctorReport {
|
||||
@@ -2376,6 +2378,8 @@ struct ResumeCommandOutcome {
|
||||
struct StatusContext {
|
||||
cwd: PathBuf,
|
||||
session_path: Option<PathBuf>,
|
||||
active_session: bool,
|
||||
session_id: Option<String>,
|
||||
loaded_config_files: usize,
|
||||
discovered_config_files: usize,
|
||||
memory_file_count: usize,
|
||||
@@ -2385,6 +2389,16 @@ struct StatusContext {
|
||||
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)]
|
||||
struct StatusUsage {
|
||||
message_count: usize,
|
||||
@@ -4993,6 +5007,8 @@ fn status_json_value(
|
||||
"kind": "status",
|
||||
"model": model,
|
||||
"permission_mode": permission_mode,
|
||||
"active_session": context.active_session,
|
||||
"session_id": context.session_id,
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
"turns": usage.turns,
|
||||
@@ -5051,9 +5067,12 @@ fn status_context(
|
||||
parse_git_status_metadata(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 worker_state = read_worker_state_snapshot(&cwd);
|
||||
Ok(StatusContext {
|
||||
cwd,
|
||||
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(),
|
||||
discovered_config_files,
|
||||
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(
|
||||
model: &str,
|
||||
usage: StatusUsage,
|
||||
@@ -5117,7 +5150,7 @@ fn format_status_report(
|
||||
context.git_summary.unstaged_files,
|
||||
context.git_summary.untracked_files,
|
||||
context.session_path.as_ref().map_or_else(
|
||||
|| "live-repl".to_string(),
|
||||
|| format_active_session(context),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
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 {
|
||||
format!(
|
||||
"Sandbox
|
||||
@@ -8946,7 +8990,7 @@ mod tests {
|
||||
let args = vec![
|
||||
"--output-format=json".to_string(),
|
||||
"--model".to_string(),
|
||||
"claude-opus".to_string(),
|
||||
"opus".to_string(),
|
||||
"explain".to_string(),
|
||||
"this".to_string(),
|
||||
];
|
||||
@@ -8954,7 +8998,7 @@ mod tests {
|
||||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::Prompt {
|
||||
prompt: "explain this".to_string(),
|
||||
model: "claude-opus".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
output_format: CliOutputFormat::Json,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
@@ -9724,15 +9768,21 @@ mod tests {
|
||||
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
// Input is ["help", "me", "debug"] so the joined prompt shorthand
|
||||
// must be "help me debug". A previous batch accidentally rewrote
|
||||
// the expected string to "$help overview" (copy-paste slip).
|
||||
// Input is ["--model", "opus", "please", "debug", "this"] so the joined
|
||||
// prompt shorthand must stay a normal multi-word prompt while still
|
||||
// honoring alias validation at parse time.
|
||||
assert_eq!(
|
||||
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
|
||||
.expect("prompt shorthand should still work"),
|
||||
parse_args(&[
|
||||
"--model".to_string(),
|
||||
"opus".to_string(),
|
||||
"please".to_string(),
|
||||
"debug".to_string(),
|
||||
"this".to_string(),
|
||||
])
|
||||
.expect("prompt shorthand should still work"),
|
||||
CliAction::Prompt {
|
||||
prompt: "help me debug".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
prompt: "please debug this".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
@@ -10346,6 +10396,8 @@ mod tests {
|
||||
&super::StatusContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
session_path: Some(PathBuf::from("session.jsonl")),
|
||||
active_session: true,
|
||||
session_id: Some("boot-status-test".to_string()),
|
||||
loaded_config_files: 2,
|
||||
discovered_config_files: 3,
|
||||
memory_file_count: 4,
|
||||
@@ -10374,10 +10426,10 @@ mod tests {
|
||||
status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked")
|
||||
);
|
||||
assert!(status.contains("Changed files 3"));
|
||||
assert!(status.contains("Session session.jsonl"));
|
||||
assert!(status.contains("Staged 1"));
|
||||
assert!(status.contains("Unstaged 1"));
|
||||
assert!(status.contains("Untracked 1"));
|
||||
assert!(status.contains("Session session.jsonl"));
|
||||
assert!(status.contains("Config files loaded 2/3"));
|
||||
assert!(status.contains("Memory files 4"));
|
||||
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"]);
|
||||
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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[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 {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
@@ -431,6 +474,26 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
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 {
|
||||
let session_path = root.join("session.jsonl");
|
||||
let mut session = Session::new()
|
||||
|
||||
@@ -11,8 +11,8 @@ use api::{
|
||||
use plugins::PluginTool;
|
||||
use reqwest::blocking::Client;
|
||||
use runtime::{
|
||||
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
|
||||
grep_search, load_system_prompt,
|
||||
check_freshness, current_boot_session_id, dedupe_superseded_commit_events, edit_file,
|
||||
execute_bash, glob_search, grep_search, load_system_prompt,
|
||||
lsp_client::LspRegistry,
|
||||
mcp_tool_bridge::McpToolRegistry,
|
||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||
@@ -3535,7 +3535,9 @@ where
|
||||
created_at: created_at.clone(),
|
||||
started_at: Some(created_at),
|
||||
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,
|
||||
derived_state: String::from("working"),
|
||||
error: None,
|
||||
@@ -3744,6 +3746,11 @@ fn persist_agent_terminal_state(
|
||||
error: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
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(
|
||||
&manifest.output_file,
|
||||
&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 {
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker));
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker));
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||
} else {
|
||||
next_manifest.current_blocker = None;
|
||||
let mut finished_summary = build_lane_finished_summary(&next_manifest, result);
|
||||
finished_summary.data.disabled_cron_ids = disable_matching_crons(&next_manifest, result);
|
||||
next_manifest.lane_events.push(
|
||||
LaneEvent::finished(iso8601_now(), finished_summary.detail).with_data(
|
||||
serde_json::to_value(&finished_summary.data)
|
||||
.expect("lane summary metadata should serialize"),
|
||||
),
|
||||
LaneEvent::finished(iso8601_now(), finished_summary.detail)
|
||||
.with_data(
|
||||
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) {
|
||||
next_manifest.lane_events.push(LaneEvent::commit_created(
|
||||
iso8601_now(),
|
||||
Some(format!("commit {}", provenance.commit)),
|
||||
provenance,
|
||||
));
|
||||
next_manifest.lane_events.push(
|
||||
LaneEvent::commit_created(
|
||||
iso8601_now(),
|
||||
Some(format!("commit {}", provenance.commit)),
|
||||
provenance,
|
||||
)
|
||||
.with_session_id(session_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
write_agent_manifest(&next_manifest)
|
||||
@@ -7761,6 +7773,9 @@ mod tests {
|
||||
assert!(manifest_contents.contains("\"status\": \"running\""));
|
||||
assert_eq!(manifest_json["laneEvents"][0]["event"], "lane.started");
|
||||
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());
|
||||
let captured_job = captured
|
||||
.lock()
|
||||
@@ -7838,10 +7853,17 @@ mod tests {
|
||||
completed_manifest_json["laneEvents"][0]["event"],
|
||||
"lane.started"
|
||||
);
|
||||
let session_id = completed_manifest_json["laneEvents"][0]["session_id"]
|
||||
.as_str()
|
||||
.expect("startup session_id should exist");
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][1]["event"],
|
||||
"lane.finished"
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][1]["session_id"],
|
||||
session_id
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][1]["data"]["qualityFloorApplied"],
|
||||
false
|
||||
@@ -7854,6 +7876,10 @@ mod tests {
|
||||
completed_manifest_json["laneEvents"][2]["event"],
|
||||
"lane.commit.created"
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][2]["session_id"],
|
||||
session_id
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][2]["data"]["commit"],
|
||||
"abc1234"
|
||||
|
||||
Reference in New Issue
Block a user