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
This commit is contained in:
Yeachan-Heo
2026-04-05 18:46:53 +00:00
parent 163cf00650
commit 7b59057034
3 changed files with 158 additions and 8 deletions

View File

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

View File

@@ -108,6 +108,7 @@ mod tests {
started_at: Some("2024-01-01T00:00:00Z".to_string()), started_at: Some("2024-01-01T00:00:00Z".to_string()),
completed_at: Some("2024-01-01T00:00:00Z".to_string()), completed_at: Some("2024-01-01T00:00:00Z".to_string()),
lane_events: vec![], lane_events: vec![],
derived_state: "working".to_string(),
current_blocker: None, current_blocker: None,
error: None, error: None,
} }

View File

@@ -23,9 +23,9 @@ use runtime::{
worker_boot::{WorkerReadySnapshot, WorkerRegistry}, worker_boot::{WorkerReadySnapshot, WorkerRegistry},
write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput, write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput,
BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput, BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput,
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus,
McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy,
RuntimeError, Session, TaskPacket, ToolError, ToolExecutor, PromptCacheEvent, RuntimeError, Session, TaskPacket, ToolError, ToolExecutor,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -2366,6 +2366,8 @@ struct AgentOutput {
lane_events: Vec<LaneEvent>, lane_events: Vec<LaneEvent>,
#[serde(rename = "currentBlocker", skip_serializing_if = "Option::is_none")] #[serde(rename = "currentBlocker", skip_serializing_if = "Option::is_none")]
current_blocker: Option<LaneEventBlocker>, current_blocker: Option<LaneEventBlocker>,
#[serde(rename = "derivedState")]
derived_state: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, error: Option<String>,
} }
@@ -3087,6 +3089,7 @@ where
completed_at: None, completed_at: None,
lane_events: vec![LaneEvent::started(iso8601_now())], lane_events: vec![LaneEvent::started(iso8601_now())],
current_blocker: None, current_blocker: None,
derived_state: String::from("working"),
error: None, error: None,
}; };
write_agent_manifest(&manifest)?; write_agent_manifest(&manifest)?;
@@ -3301,6 +3304,8 @@ fn persist_agent_terminal_state(
next_manifest.status = status.to_string(); next_manifest.status = status.to_string();
next_manifest.completed_at = Some(iso8601_now()); next_manifest.completed_at = Some(iso8601_now());
next_manifest.current_blocker.clone_from(&blocker); 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; next_manifest.error = error;
if let Some(blocker) = blocker { if let Some(blocker) = blocker {
next_manifest next_manifest
@@ -3317,10 +3322,92 @@ fn persist_agent_terminal_state(
next_manifest next_manifest
.lane_events .lane_events
.push(LaneEvent::finished(iso8601_now(), compressed_detail)); .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) 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<LaneCommitProvenance> {
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<String> {
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<String> {
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> { fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> {
use std::io::Write as _; use std::io::Write as _;
@@ -4956,10 +5043,10 @@ mod tests {
use super::{ use super::{
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure, agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs, derive_agent_state, execute_agent_with_spawn, execute_tool, final_assistant_text,
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
SubagentToolExecutor, GlobalToolRegistry, LaneEventName, LaneFailureClass, SubagentToolExecutor,
}; };
use api::OutputContentBlock; use api::OutputContentBlock;
use runtime::{ use runtime::{
@@ -5846,7 +5933,7 @@ mod tests {
persist_agent_terminal_state( persist_agent_terminal_state(
&job.manifest, &job.manifest,
"completed", "completed",
Some("Finished successfully"), Some("Finished successfully in commit abc1234"),
None, None,
) )
}, },
@@ -5869,7 +5956,19 @@ mod tests {
completed_manifest_json["laneEvents"][1]["event"], completed_manifest_json["laneEvents"][1]["event"],
"lane.finished" "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!(completed_manifest_json["currentBlocker"].is_null());
assert_eq!(
completed_manifest_json["derivedState"],
"finished_cleanable"
);
let failed = execute_agent_with_spawn( let failed = execute_agent_with_spawn(
AgentInput { AgentInput {
@@ -5916,6 +6015,7 @@ mod tests {
failed_manifest_json["laneEvents"][2]["failureClass"], failed_manifest_json["laneEvents"][2]["failureClass"],
"tool_runtime" "tool_runtime"
); );
assert_eq!(failed_manifest_json["derivedState"], "truly_idle");
let spawn_error = execute_agent_with_spawn( let spawn_error = execute_agent_with_spawn(
AgentInput { AgentInput {
@@ -5949,11 +6049,59 @@ mod tests {
spawn_error_manifest_json["currentBlocker"]["failureClass"], spawn_error_manifest_json["currentBlocker"]["failureClass"],
"infra" "infra"
); );
assert_eq!(spawn_error_manifest_json["derivedState"], "truly_idle");
std::env::remove_var("CLAWD_AGENT_STORE"); std::env::remove_var("CLAWD_AGENT_STORE");
let _ = std::fs::remove_dir_all(dir); 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] #[test]
fn lane_failure_taxonomy_normalizes_common_blockers() { fn lane_failure_taxonomy_normalizes_common_blockers() {
let cases = [ let cases = [