mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 08:04:50 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<LaneEvent>,
|
||||
#[serde(rename = "currentBlocker", skip_serializing_if = "Option::is_none")]
|
||||
current_blocker: Option<LaneEventBlocker>,
|
||||
#[serde(rename = "derivedState")]
|
||||
derived_state: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
@@ -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<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> {
|
||||
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 = [
|
||||
|
||||
Reference in New Issue
Block a user