From 7b59057034a137fe8fc067a18c7c22a2bfbd5413 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Sun, 5 Apr 2026 18:46:53 +0000 Subject: [PATCH] Classify quiet agent states before they look stale Persist derived machine states for agent manifests so downstream monitors can distinguish working, blocked, degraded, and finished-cleanable lanes without inferring everything from prose. This also records commit provenance in terminal-state manifests and marks the new session-state classification roadmap item as done. Constraint: Keep the change scoped to manifest persistence and tests without introducing a new monitoring service layer Rejected: Leave state classification as downstream text scraping only | repeated dogfood runs showed quiet/finished lanes being misreported as stale Confidence: medium Scope-risk: narrow Directive: Reuse derived_state + commit provenance from manifests before adding any new stale-session heuristics elsewhere Tested: python .github/scripts/check_doc_source_of_truth.py Tested: cd rust && cargo fmt --all --check Tested: cd rust && cargo test -q -p tools Tested: cd rust && cargo clippy -p tools --all-targets --no-deps -- -D warnings Not-tested: full cargo clippy --workspace --all-targets -- -D warnings still fails on unrelated pre-existing runtime lint debt --- ROADMAP.md | 1 + rust/crates/tools/src/lane_completion.rs | 1 + rust/crates/tools/src/lib.rs | 164 +++++++++++++++++++++-- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b33b05b..059e4ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -306,6 +306,7 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = 17. **Orphaned module integration audit** — **done**: `runtime` now keeps `session_control` and `trust_resolver` behind `#[cfg(test)]` until they are wired into a real non-test execution path, so normal builds no longer advertise dead clawability surface area. 18. **Context-window preflight gap** — **done**: provider request sizing now emits `context_window_blocked` before oversized requests leave the process, using a model-context registry instead of the old naive max-token heuristic. 19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths. +20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions. **P3 — Swarm efficiency** 13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation 14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them diff --git a/rust/crates/tools/src/lane_completion.rs b/rust/crates/tools/src/lane_completion.rs index c44c508..e4eecce 100644 --- a/rust/crates/tools/src/lane_completion.rs +++ b/rust/crates/tools/src/lane_completion.rs @@ -108,6 +108,7 @@ mod tests { started_at: Some("2024-01-01T00:00:00Z".to_string()), completed_at: Some("2024-01-01T00:00:00Z".to_string()), lane_events: vec![], + derived_state: "working".to_string(), current_blocker: None, error: None, } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 8efa5f3..31a162c 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -23,9 +23,9 @@ use runtime::{ worker_boot::{WorkerReadySnapshot, WorkerRegistry}, write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput, BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput, - LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, - McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, - RuntimeError, Session, TaskPacket, ToolError, ToolExecutor, + LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, + LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, + PromptCacheEvent, RuntimeError, Session, TaskPacket, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -2366,6 +2366,8 @@ struct AgentOutput { lane_events: Vec, #[serde(rename = "currentBlocker", skip_serializing_if = "Option::is_none")] current_blocker: Option, + #[serde(rename = "derivedState")] + derived_state: String, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } @@ -3087,6 +3089,7 @@ where completed_at: None, lane_events: vec![LaneEvent::started(iso8601_now())], current_blocker: None, + derived_state: String::from("working"), error: None, }; write_agent_manifest(&manifest)?; @@ -3301,6 +3304,8 @@ fn persist_agent_terminal_state( next_manifest.status = status.to_string(); next_manifest.completed_at = Some(iso8601_now()); next_manifest.current_blocker.clone_from(&blocker); + next_manifest.derived_state = + derive_agent_state(status, result, error.as_deref(), blocker.as_ref()).to_string(); next_manifest.error = error; if let Some(blocker) = blocker { next_manifest @@ -3317,10 +3322,92 @@ fn persist_agent_terminal_state( next_manifest .lane_events .push(LaneEvent::finished(iso8601_now(), compressed_detail)); + if let Some(provenance) = maybe_commit_provenance(result) { + next_manifest.lane_events.push(LaneEvent::commit_created( + iso8601_now(), + Some(format!("commit {}", provenance.commit)), + provenance, + )); + } } write_agent_manifest(&next_manifest) } +fn derive_agent_state( + status: &str, + result: Option<&str>, + error: Option<&str>, + blocker: Option<&LaneEventBlocker>, +) -> &'static str { + let normalized_status = status.trim().to_ascii_lowercase(); + let normalized_error = error.unwrap_or_default().to_ascii_lowercase(); + + if normalized_status == "running" { + return "working"; + } + if normalized_status == "completed" { + return if result.is_some_and(|value| !value.trim().is_empty()) { + "finished_cleanable" + } else { + "finished_pending_report" + }; + } + if normalized_error.contains("background") { + return "blocked_background_job"; + } + if normalized_error.contains("merge conflict") || normalized_error.contains("cherry-pick") { + return "blocked_merge_conflict"; + } + if normalized_error.contains("mcp") { + return "degraded_mcp"; + } + if normalized_error.contains("transport") + || normalized_error.contains("broken pipe") + || normalized_error.contains("connection") + || normalized_error.contains("interrupted") + { + return "interrupted_transport"; + } + if blocker.is_some() { + return "truly_idle"; + } + "truly_idle" +} + +fn maybe_commit_provenance(result: Option<&str>) -> Option { + let commit = extract_commit_sha(result?)?; + let branch = current_git_branch().unwrap_or_else(|| "unknown".to_string()); + let worktree = std::env::current_dir() + .ok() + .map(|path| path.display().to_string()); + Some(LaneCommitProvenance { + commit: commit.clone(), + branch, + worktree, + canonical_commit: Some(commit.clone()), + superseded_by: None, + lineage: vec![commit], + }) +} + +fn extract_commit_sha(result: &str) -> Option { + result + .split(|c: char| !c.is_ascii_hexdigit()) + .find(|token| token.len() >= 7 && token.len() <= 40) + .map(str::to_string) +} + +fn current_git_branch() -> Option { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> { use std::io::Write as _; @@ -4956,10 +5043,10 @@ mod tests { use super::{ agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure, - execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs, - permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, - run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, - SubagentToolExecutor, + derive_agent_state, execute_agent_with_spawn, execute_tool, final_assistant_text, + maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin, + persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob, + GlobalToolRegistry, LaneEventName, LaneFailureClass, SubagentToolExecutor, }; use api::OutputContentBlock; use runtime::{ @@ -5846,7 +5933,7 @@ mod tests { persist_agent_terminal_state( &job.manifest, "completed", - Some("Finished successfully"), + Some("Finished successfully in commit abc1234"), None, ) }, @@ -5869,7 +5956,19 @@ mod tests { completed_manifest_json["laneEvents"][1]["event"], "lane.finished" ); + assert_eq!( + completed_manifest_json["laneEvents"][2]["event"], + "lane.commit.created" + ); + assert_eq!( + completed_manifest_json["laneEvents"][2]["data"]["commit"], + "abc1234" + ); assert!(completed_manifest_json["currentBlocker"].is_null()); + assert_eq!( + completed_manifest_json["derivedState"], + "finished_cleanable" + ); let failed = execute_agent_with_spawn( AgentInput { @@ -5916,6 +6015,7 @@ mod tests { failed_manifest_json["laneEvents"][2]["failureClass"], "tool_runtime" ); + assert_eq!(failed_manifest_json["derivedState"], "truly_idle"); let spawn_error = execute_agent_with_spawn( AgentInput { @@ -5949,11 +6049,59 @@ mod tests { spawn_error_manifest_json["currentBlocker"]["failureClass"], "infra" ); + assert_eq!(spawn_error_manifest_json["derivedState"], "truly_idle"); std::env::remove_var("CLAWD_AGENT_STORE"); let _ = std::fs::remove_dir_all(dir); } + #[test] + fn agent_state_classification_covers_finished_and_specific_blockers() { + assert_eq!(derive_agent_state("running", None, None, None), "working"); + assert_eq!( + derive_agent_state("completed", Some("done"), None, None), + "finished_cleanable" + ); + assert_eq!( + derive_agent_state("completed", None, None, None), + "finished_pending_report" + ); + assert_eq!( + derive_agent_state("failed", None, Some("mcp handshake timed out"), None), + "degraded_mcp" + ); + assert_eq!( + derive_agent_state( + "failed", + None, + Some("background terminal still running"), + None + ), + "blocked_background_job" + ); + assert_eq!( + derive_agent_state("failed", None, Some("merge conflict while rebasing"), None), + "blocked_merge_conflict" + ); + assert_eq!( + derive_agent_state( + "failed", + None, + Some("transport interrupted after partial progress"), + None + ), + "interrupted_transport" + ); + } + + #[test] + fn commit_provenance_is_extracted_from_agent_results() { + let provenance = maybe_commit_provenance(Some("landed as commit deadbee with clean push")) + .expect("commit provenance"); + assert_eq!(provenance.commit, "deadbee"); + assert_eq!(provenance.canonical_commit.as_deref(), Some("deadbee")); + assert_eq!(provenance.lineage, vec!["deadbee".to_string()]); + } #[test] fn lane_failure_taxonomy_normalizes_common_blockers() { let cases = [