mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +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.
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user