Compare commits

..

7 Commits

8 changed files with 382 additions and 36 deletions

View File

@@ -5014,3 +5014,40 @@ ear], /color [scheme], /effort [low|medium|high], /fast, /summary, /tag [label],
**Blocker.** None. Reuses existing `stale_base` module; no new logic needed, just a missing call site.
**Source.** Jobdori dogfood 2026-04-20 against `/tmp/jobdori-129-mcp-cred-order` + `/tmp/stale-branch` in response to 10-min cron cycle. Confirmed: `claw doctor` on branch 5 commits behind main says "Status: ok" but `prompt` dispatch would warn "worktree HEAD does not match expected base commit." Gap is a missing invocation of the already-correct `run_stale_base_preflight()` in the `doctor` action handler. Joins **Boot preflight / doctor contract (#80#83, #114)** family — doctor is the single machine-readable preflight surface; missing checks degrade operator trust. Also relates to **Silent-state inventory** cluster (#102/#127/#129/#245) because stale-base is a runtime truth ("my branch is behind main") that the preflight surface (doctor) does not expose.
## Pinpoint #135. `claw status --json` missing `active_session` boolean and `session.id` cross-reference — two surfaces that should be unified are inconsistent
**Gap.** `claw status --json` exposes a snapshot of the runtime state but does not include (1) a stable `session.id` field (filed as #134 — the fix from the other side is to emit it in lane events; the consumer side needs it queryable via `status` too) and (2) an `active_session: bool` that tells an orchestrator whether the runtime currently has a live session in flight. An external orchestrator (Clawhip, remote agent) running `claw status --json` after sending a prompt has no machine-readable way to confirm whether the session is alive, idle, or stalled without parsing log output.
**Trace path.**
- `claw status --json` (dispatcher in `main.rs` `CliAction::Status`) renders a `StatusReport` struct that includes `git_state`, `config`, `model`, `provider` — but no `session_id` or `active_session` fields.
- `claw status` (text mode) also omits both.
- The `session.id` fix from #134 introduces a UUID at session init; it should be threaded through to `StatusReport` so the round-trip is complete: emit on startup event → queryable via `status --json` → correlatable in lane events.
**Fix shape (~30 lines).**
1. Add `session_id: Option<String>` and `active_session: bool` to `StatusReport` struct. Both `null`/`false` when no session is active. When a session is running, `session_id` is the same UUID emitted in the startup lane event (#134).
2. Thread the session state into the `status` handler via a shared `Arc<Mutex<SessionState>>` or equivalent (same mechanism #134 uses for startup event emission).
3. Text-mode `claw status` surfaces the value: `Session: active (id: abc123)` or `Session: idle`.
4. Regression tests: (a) `claw status --json` before any prompt → `active_session: false, session_id: null`. (b) `claw status --json` during a prompt session → `active_session: true, session_id: <uuid>`. (c) UUID matches the `session.id` in the first lane event of the same run.
**Acceptance.** An orchestrator can poll `claw status --json` and determine: is there a live session? What is its correlation ID? Does it match the ID from the last startup event? This closes the round-trip opened by #134.
**Blocker.** Depends on #134 (session.id generation at init). Can be filed and implemented together.
**Source.** Jobdori dogfood 2026-04-21 06:53 KST on main HEAD `2c42f8b` during recurring cron cycle. Direct sibling of #134 — #134 covers the event-emission side, #135 covers the query side. Joins **Session identity completeness** (§4.7) and **status surface completeness** cluster (#80/#83/#114/#122). Natural bundle: **#134 + #135** closes the full session-identity round-trip. Session tally: ROADMAP #135.
## Pinpoint #134. No run/correlation ID at session boundary — every observer must infer session identity from timing or prompt content
**Gap.** When a `claw` session starts, no stable correlation ID is emitted in the first structured event (or any event). Every observer — lane event consumer, log aggregator, Clawhip router, test harness — has to infer session identity from timing proximity or prompt content. If two sessions start in close succession there is no unambiguous way to attribute subsequent events to the correct session. `claw status --json` returns session metadata but does not expose an opaque stable ID that could be used as a correlation key across the event stream.
**Fix shape.**
- Emit `session.id` (opaque, stable, scoped to this boot) in the first structured event at startup
- Include same ID in all subsequent lane events as `session_id` field
- Expose via `claw status --json` so callers can retrieve the active session's ID from outside
- Add regression: golden-fixture asserting `session.id` is present in startup event and value matches across a multi-event trace
**Acceptance.** Any observer can correlate all events from a session using `session_id` without parsing prompt content or relying on timestamp proximity. `claw status --json` exposes the current session's ID.
**Blocker.** None. Requires a UUID/nanoid generated at session init and threaded through the event emitter.
**Source.** Jobdori dogfood 2026-04-21 01:54 KST on main HEAD `50e3fa3` during recurring cron cycle. Joins **Session identity completeness at creation time** (ROADMAP §4.7) — §4.7 covers identity fields at creation time; #134 covers the stable correlation handle that ties those fields to downstream events. Joins **Event provenance / environment labeling** (§4.6) — provenance requires a stable anchor; without `session.id` the provenance chain is broken at the root. Natural bundle with **#241** (no startup run/correlation id, filed by gaebal-gajae 2026-04-20) — #241 approached from the startup cluster; #134 approaches from the event-stream observer side. Same root fix closes both. Session tally: ROADMAP #134.

View File

@@ -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());
}
}

View File

@@ -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,

View 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());
}
}

View File

@@ -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

View File

@@ -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"));

View File

@@ -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()

View File

@@ -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"