mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
feat(worker_boot): emit .claw/worker-state.json on every status transition
WorkerStatus is fully tracked in worker_boot.rs but was invisible to external observers (clawhip, orchestrators) because opencode serve's HTTP server is upstream and not ours to extend. Solution: atomic file-based observability. - emit_state_file() writes .claw/worker-state.json on every push_event() call (tmp write + rename for atomicity) - Snapshot includes: worker_id, status, is_ready, trust_gate_cleared, prompt_in_flight, last_event, updated_at - Add 'claw state' CLI subcommand to read and print the file - Add regression test: emit_state_file_writes_worker_status_on_transition verifies spawning→ready_for_prompt transition is reflected on disk This closes the /state dogfood gap without requiring any upstream opencode changes. Clawhip can now distinguish a truly stalled worker (status: trust_required or running with no recent updated_at) from a quiet-but-progressing one.
This commit is contained in:
@@ -560,6 +560,7 @@ fn push_event(
|
|||||||
let timestamp = now_secs();
|
let timestamp = now_secs();
|
||||||
let seq = worker.events.len() as u64 + 1;
|
let seq = worker.events.len() as u64 + 1;
|
||||||
worker.updated_at = timestamp;
|
worker.updated_at = timestamp;
|
||||||
|
worker.status = status;
|
||||||
worker.events.push(WorkerEvent {
|
worker.events.push(WorkerEvent {
|
||||||
seq,
|
seq,
|
||||||
kind,
|
kind,
|
||||||
@@ -568,6 +569,45 @@ fn push_event(
|
|||||||
payload,
|
payload,
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
emit_state_file(worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write current worker state to `.claw/worker-state.json` under the worker's cwd.
|
||||||
|
/// This is the file-based observability surface: external observers (clawhip, orchestrators)
|
||||||
|
/// poll this file instead of requiring an HTTP route on the opencode binary.
|
||||||
|
fn emit_state_file(worker: &Worker) {
|
||||||
|
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
|
||||||
|
if let Err(_) = std::fs::create_dir_all(&state_dir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let state_path = state_dir.join("worker-state.json");
|
||||||
|
let tmp_path = state_dir.join("worker-state.json.tmp");
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct StateSnapshot<'a> {
|
||||||
|
worker_id: &'a str,
|
||||||
|
status: WorkerStatus,
|
||||||
|
is_ready: bool,
|
||||||
|
trust_gate_cleared: bool,
|
||||||
|
prompt_in_flight: bool,
|
||||||
|
last_event: Option<&'a WorkerEvent>,
|
||||||
|
updated_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = StateSnapshot {
|
||||||
|
worker_id: &worker.worker_id,
|
||||||
|
status: worker.status,
|
||||||
|
is_ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||||
|
trust_gate_cleared: worker.trust_gate_cleared,
|
||||||
|
prompt_in_flight: worker.prompt_in_flight,
|
||||||
|
last_event: worker.events.last(),
|
||||||
|
updated_at: worker.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&snapshot) {
|
||||||
|
let _ = std::fs::write(&tmp_path, json);
|
||||||
|
let _ = std::fs::rename(&tmp_path, &state_path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path_matches_allowlist(cwd: &str, trusted_root: &str) -> bool {
|
fn path_matches_allowlist(cwd: &str, trusted_root: &str) -> bool {
|
||||||
@@ -1058,6 +1098,38 @@ mod tests {
|
|||||||
.any(|event| event.kind == WorkerEventKind::Failed));
|
.any(|event| event.kind == WorkerEventKind::Failed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_state_file_writes_worker_status_on_transition() {
|
||||||
|
let cwd_path = std::env::temp_dir().join(format!("claw-state-test-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()));
|
||||||
|
std::fs::create_dir_all(&cwd_path).expect("test dir should create");
|
||||||
|
let cwd = cwd_path.to_str().expect("test path should be utf8");
|
||||||
|
let registry = WorkerRegistry::new();
|
||||||
|
let worker = registry.create(cwd, &[], true);
|
||||||
|
|
||||||
|
// After create the worker is Spawning — state file should exist
|
||||||
|
let state_path = cwd_path.join(".claw").join("worker-state.json");
|
||||||
|
assert!(state_path.exists(), "state file should exist after worker creation");
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(&state_path).expect("state file should be readable");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).expect("state file should be valid JSON");
|
||||||
|
assert_eq!(value["status"].as_str(), Some("spawning"), "initial status should be spawning");
|
||||||
|
assert_eq!(value["is_ready"].as_bool(), Some(false));
|
||||||
|
|
||||||
|
// Transition to ReadyForPrompt by observing trust-cleared text
|
||||||
|
registry
|
||||||
|
.observe(&worker.worker_id, "Ready for input\n>")
|
||||||
|
.expect("observe ready should succeed");
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(&state_path).expect("state file should be readable after observe");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).expect("state file should be valid JSON after observe");
|
||||||
|
assert_eq!(
|
||||||
|
value["status"].as_str(),
|
||||||
|
Some("ready_for_prompt"),
|
||||||
|
"status should be ready_for_prompt after observe"
|
||||||
|
);
|
||||||
|
assert_eq!(value["is_ready"].as_bool(), Some(true), "is_ready should be true when ReadyForPrompt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn observe_completion_accepts_normal_finish_with_tokens() {
|
fn observe_completion_accepts_normal_finish_with_tokens() {
|
||||||
let registry = WorkerRegistry::new();
|
let registry = WorkerRegistry::new();
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
CliAction::Login { output_format } => run_login(output_format)?,
|
CliAction::Login { output_format } => run_login(output_format)?,
|
||||||
CliAction::Logout { output_format } => run_logout(output_format)?,
|
CliAction::Logout { output_format } => run_logout(output_format)?,
|
||||||
CliAction::Doctor { output_format } => run_doctor(output_format)?,
|
CliAction::Doctor { output_format } => run_doctor(output_format)?,
|
||||||
|
CliAction::State { output_format } => run_worker_state(output_format)?,
|
||||||
CliAction::Init { output_format } => run_init(output_format)?,
|
CliAction::Init { output_format } => run_init(output_format)?,
|
||||||
CliAction::Export {
|
CliAction::Export {
|
||||||
session_reference,
|
session_reference,
|
||||||
@@ -293,6 +294,9 @@ enum CliAction {
|
|||||||
Doctor {
|
Doctor {
|
||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
},
|
},
|
||||||
|
State {
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
},
|
||||||
Init {
|
Init {
|
||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
},
|
},
|
||||||
@@ -611,6 +615,7 @@ fn parse_single_word_command_alias(
|
|||||||
})),
|
})),
|
||||||
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
||||||
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
|
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
|
||||||
|
"state" => Some(Ok(CliAction::State { output_format })),
|
||||||
other => bare_slash_command_guidance(other).map(Err),
|
other => bare_slash_command_guidance(other).map(Err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1322,6 +1327,32 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
|||||||
///
|
///
|
||||||
/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are
|
/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are
|
||||||
/// dispatched through [`tools::execute_tool`], so this server exposes exactly
|
/// dispatched through [`tools::execute_tool`], so this server exposes exactly
|
||||||
|
/// Read `.claw/worker-state.json` from the current working directory and print it.
|
||||||
|
/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs`
|
||||||
|
/// atomically writes state transitions here so external observers (clawhip, orchestrators)
|
||||||
|
/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary.
|
||||||
|
fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
let state_path = cwd.join(".claw").join("worker-state.json");
|
||||||
|
if !state_path.exists() {
|
||||||
|
match output_format {
|
||||||
|
CliOutputFormat::Text => println!("No worker state file found at {}", state_path.display()),
|
||||||
|
CliOutputFormat::Json => println!("{}", serde_json::json!({"error": "no_state_file", "path": state_path.display().to_string()})),
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(&state_path)?;
|
||||||
|
match output_format {
|
||||||
|
CliOutputFormat::Text => println!("{raw}"),
|
||||||
|
CliOutputFormat::Json => {
|
||||||
|
// Validate it parses as JSON before re-emitting
|
||||||
|
let _: serde_json::Value = serde_json::from_str(&raw)?;
|
||||||
|
println!("{raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// the same surface the in-process agent loop uses.
|
/// the same surface the in-process agent loop uses.
|
||||||
fn run_mcp_serve() -> Result<(), Box<dyn std::error::Error>> {
|
fn run_mcp_serve() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let tools = mvp_tool_specs()
|
let tools = mvp_tool_specs()
|
||||||
@@ -8547,6 +8578,19 @@ mod tests {
|
|||||||
output_format: CliOutputFormat::Text,
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["state".to_string()]).expect("state should parse"),
|
||||||
|
CliAction::State {
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["state".to_string(), "--output-format".to_string(), "json".to_string()])
|
||||||
|
.expect("state --output-format json should parse"),
|
||||||
|
CliAction::State {
|
||||||
|
output_format: CliOutputFormat::Json,
|
||||||
|
}
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["init".to_string()]).expect("init should parse"),
|
parse_args(&["init".to_string()]).expect("init should parse"),
|
||||||
CliAction::Init {
|
CliAction::Init {
|
||||||
|
|||||||
Reference in New Issue
Block a user