@team-agent/installer 0.5.41 → 0.5.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +129 -54
- package/crates/team-agent/src/cli/diagnose.rs +1 -2
- package/crates/team-agent/src/cli/emit.rs +1 -7
- package/crates/team-agent/src/cli/helpers.rs +3 -1
- package/crates/team-agent/src/cli/leader.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +27 -7
- package/crates/team-agent/src/cli/named_address.rs +14 -5
- package/crates/team-agent/src/cli/profile.rs +19 -7
- package/crates/team-agent/src/cli/send.rs +9 -3
- package/crates/team-agent/src/cli/status.rs +8 -30
- package/crates/team-agent/src/cli/status_port.rs +1341 -1272
- package/crates/team-agent/src/cli/tests/base.rs +738 -660
- package/crates/team-agent/src/cli/tests/compile.rs +45 -18
- package/crates/team-agent/src/cli/tests/divergence.rs +462 -444
- package/crates/team-agent/src/cli/tests/lane_c.rs +365 -282
- package/crates/team-agent/src/cli/tests/leader_watch.rs +356 -329
- package/crates/team-agent/src/cli/tests/main_preserved.rs +672 -564
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +284 -224
- package/crates/team-agent/src/cli/tests/mod.rs +17 -7
- package/crates/team-agent/src/cli/tests/named_address.rs +8 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +10 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +314 -273
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +26 -15
- package/crates/team-agent/src/cli/tests/status_send.rs +707 -629
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +20 -4
- package/crates/team-agent/src/cli/tests/verb_profile.rs +46 -17
- package/crates/team-agent/src/cli/tests/verb_validate.rs +15 -3
- package/crates/team-agent/src/codex_app_server.rs +2 -5
- package/crates/team-agent/src/compiler/tests.rs +139 -33
- package/crates/team-agent/src/compiler.rs +55 -22
- package/crates/team-agent/src/conpty/backend.rs +23 -33
- package/crates/team-agent/src/coordinator/backoff.rs +2 -7
- package/crates/team-agent/src/coordinator/conpty_shim.rs +55 -67
- package/crates/team-agent/src/coordinator/health.rs +46 -31
- package/crates/team-agent/src/coordinator/mod.rs +3 -3
- package/crates/team-agent/src/coordinator/orphan.rs +22 -10
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +47 -52
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +55 -19
- package/crates/team-agent/src/coordinator/tests/basics.rs +179 -41
- package/crates/team-agent/src/coordinator/tests/daemon.rs +53 -13
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +78 -19
- package/crates/team-agent/src/coordinator/tests/main_preserved.rs +61 -11
- package/crates/team-agent/src/coordinator/tests/mod.rs +33 -39
- package/crates/team-agent/src/coordinator/tests/spine.rs +52 -12
- package/crates/team-agent/src/coordinator/tests/takeover.rs +73 -15
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +50 -15
- package/crates/team-agent/src/coordinator/tests/watch.rs +74 -20
- package/crates/team-agent/src/coordinator/tick.rs +19 -1
- package/crates/team-agent/src/db/message_store.rs +138 -30
- package/crates/team-agent/src/db/migration.rs +249 -61
- package/crates/team-agent/src/db/schema.rs +303 -82
- package/crates/team-agent/src/diagnose/comms.rs +9 -2
- package/crates/team-agent/src/diagnose/mod.rs +1 -3
- package/crates/team-agent/src/diagnose/orphans.rs +79 -61
- package/crates/team-agent/src/event_log.rs +70 -16
- package/crates/team-agent/src/layout/manager.rs +15 -4
- package/crates/team-agent/src/layout/mod.rs +4 -4
- package/crates/team-agent/src/layout/overlay.rs +10 -3
- package/crates/team-agent/src/layout/placement.rs +5 -1
- package/crates/team-agent/src/layout/recovery.rs +4 -2
- package/crates/team-agent/src/layout/runtime_sessions.rs +7 -7
- package/crates/team-agent/src/layout/sessions.rs +17 -9
- package/crates/team-agent/src/layout/tmux_endpoint.rs +1 -1
- package/crates/team-agent/src/layout/worker_env.rs +52 -19
- package/crates/team-agent/src/leader/helpers.rs +7 -1
- package/crates/team-agent/src/leader/lease.rs +195 -82
- package/crates/team-agent/src/leader/owner_bind.rs +55 -22
- package/crates/team-agent/src/leader/rediscover/tests.rs +88 -24
- package/crates/team-agent/src/leader/rediscover.rs +74 -25
- package/crates/team-agent/src/leader/start.rs +75 -54
- package/crates/team-agent/src/leader/takeover.rs +46 -11
- package/crates/team-agent/src/leader/tests/basics.rs +320 -167
- package/crates/team-agent/src/leader/tests/byte_findings.rs +361 -219
- package/crates/team-agent/src/leader/tests/identity.rs +428 -356
- package/crates/team-agent/src/leader/tests/idle.rs +285 -254
- package/crates/team-agent/src/leader/tests/lease_api.rs +338 -274
- package/crates/team-agent/src/leader/tests/lease_claim.rs +643 -593
- package/crates/team-agent/src/leader/tests/mod.rs +115 -99
- package/crates/team-agent/src/leader/tests/rediscover.rs +74 -22
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +237 -211
- package/crates/team-agent/src/lib.rs +4 -4
- package/crates/team-agent/src/lifecycle/display.rs +7 -3
- package/crates/team-agent/src/lifecycle/launch.rs +6 -1
- package/crates/team-agent/src/lifecycle/mod.rs +9 -1
- package/crates/team-agent/src/lifecycle/profile_launch.rs +77 -34
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -6
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +1 -4
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +6 -5
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -22
- package/crates/team-agent/src/lifecycle/restart/remove.rs +45 -35
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +63 -17
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +251 -84
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +198 -48
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +152 -32
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -5
- package/crates/team-agent/src/lifecycle/tests.rs +2 -2
- package/crates/team-agent/src/main.rs +4 -4
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +47 -20
- package/crates/team-agent/src/mcp_server/mod.rs +11 -2
- package/crates/team-agent/src/mcp_server/types.rs +14 -3
- package/crates/team-agent/src/mcp_server/wire.rs +230 -68
- package/crates/team-agent/src/messaging/delivery.rs +20 -18
- package/crates/team-agent/src/messaging/helpers.rs +25 -4
- package/crates/team-agent/src/messaging/leader_receiver.rs +4 -5
- package/crates/team-agent/src/messaging/mod.rs +2 -3
- package/crates/team-agent/src/messaging/selftest.rs +46 -26
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +47 -12
- package/crates/team-agent/src/messaging/tests/runtime.rs +57 -22
- package/crates/team-agent/src/messaging/tests/spine.rs +154 -40
- package/crates/team-agent/src/messaging/tests/wave2.rs +31 -32
- package/crates/team-agent/src/messaging/trust.rs +25 -2
- package/crates/team-agent/src/messaging/watchers.rs +37 -9
- package/crates/team-agent/src/model/enums.rs +65 -16
- package/crates/team-agent/src/model/ids.rs +12 -3
- package/crates/team-agent/src/model/paths.rs +28 -7
- package/crates/team-agent/src/model/permissions.rs +176 -33
- package/crates/team-agent/src/model/routing.rs +66 -20
- package/crates/team-agent/src/model/spec.rs +365 -69
- package/crates/team-agent/src/model/task_graph.rs +36 -9
- package/crates/team-agent/src/model/yaml/tests.rs +24 -6
- package/crates/team-agent/src/model/yaml.rs +7 -6
- package/crates/team-agent/src/packaging/install.rs +23 -9
- package/crates/team-agent/src/packaging/migrate.rs +5 -7
- package/crates/team-agent/src/packaging/mod.rs +9 -1
- package/crates/team-agent/src/packaging/repair.rs +13 -6
- package/crates/team-agent/src/packaging/tests.rs +63 -16
- package/crates/team-agent/src/packaging/types.rs +22 -7
- package/crates/team-agent/src/platform/argv.rs +4 -1
- package/crates/team-agent/src/platform/file_lock.rs +22 -8
- package/crates/team-agent/src/platform/process.rs +21 -22
- package/crates/team-agent/src/provider/adapters/claude.rs +1 -3
- package/crates/team-agent/src/provider/approvals/parsing.rs +134 -26
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +13 -3
- package/crates/team-agent/src/provider/classify.rs +127 -42
- package/crates/team-agent/src/provider/faults.rs +17 -5
- package/crates/team-agent/src/provider/helpers.rs +6 -5
- package/crates/team-agent/src/provider/startup_prompt.rs +75 -27
- package/crates/team-agent/src/state/repository.rs +27 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +1637 -1398
- package/crates/team-agent/src/tmux_backend.rs +80 -44
- package/crates/team-agent/src/topology.rs +27 -20
- package/crates/team-agent/src/transport/test_support.rs +20 -23
- package/crates/team-agent/src/transport/tests/behavior.rs +292 -293
- package/crates/team-agent/src/transport/tests/mod.rs +178 -187
- package/crates/team-agent/src/transport/tests/wire.rs +561 -525
- package/crates/team-agent/src/transport.rs +12 -17
- package/crates/team-agent/src/transport_factory.rs +29 -14
- package/package.json +4 -4
|
@@ -4,932 +4,976 @@ use crate::state::projection::OwnerTeamResolution;
|
|
|
4
4
|
use crate::transport::Transport;
|
|
5
5
|
use rusqlite::params;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
/// `status.status(workspace, as_json, compact)`(`queries.py:33`,**有副作用**:capture→refresh→save)。
|
|
8
|
+
pub fn status(workspace: &Path, compact: bool, detail: bool) -> Result<Value, CliError> {
|
|
9
|
+
let state = read_runtime_state(workspace);
|
|
10
|
+
status_scoped(workspace, &state, None, compact, detail)
|
|
11
|
+
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
13
|
+
pub fn status_scoped(
|
|
14
|
+
workspace: &Path,
|
|
15
|
+
state: &Value,
|
|
16
|
+
owner_team_id: Option<&str>,
|
|
17
|
+
compact: bool,
|
|
18
|
+
detail: bool,
|
|
19
|
+
) -> Result<Value, CliError> {
|
|
20
|
+
// commands.py:99 — `--json --detail` maps to compact=False: detail wins and
|
|
21
|
+
// returns the FULL payload.
|
|
22
|
+
let compact = compact && !detail;
|
|
23
|
+
let resolved_owner_team_id = resolve_status_owner_team(workspace, owner_team_id)?;
|
|
24
|
+
let owner_team_id = resolved_owner_team_id.as_deref().or(owner_team_id);
|
|
25
|
+
let health = crate::coordinator::coordinator_health(&crate::coordinator::WorkspacePath::new(
|
|
26
|
+
workspace.to_path_buf(),
|
|
27
|
+
));
|
|
28
|
+
let store = crate::message_store::MessageStore::open(workspace)
|
|
29
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
30
|
+
let conn = crate::db::schema::open_db(store.db_path())
|
|
31
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
32
|
+
// B-5 / 036b N38 explicable — status 出口 runtime 块:把 coordinator_health
|
|
33
|
+
// (现状)+ undelivered backlog count 一起暴露;coordinator not running ∧
|
|
34
|
+
// backlog>0 才挂 down-hint(anti-nag)。auto-recovery 不做(user 已裁)。
|
|
35
|
+
let coordinator_running = coordinator_status_running(&health);
|
|
36
|
+
let undelivered_backlog = count_undelivered_backlog(&conn, owner_team_id)?;
|
|
37
|
+
let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
|
|
38
|
+
let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
|
|
39
|
+
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): resolve
|
|
40
|
+
// RuntimeFreshness once and thread it through the runtime block
|
|
41
|
+
// and per-agent enrichment. This is the single-source read that
|
|
42
|
+
// makes host-boot / coordinator / provider-exit staleness win
|
|
43
|
+
// over cached state.agents and DB agent_health.
|
|
44
|
+
let freshness = compute_runtime_freshness(workspace, state, &health);
|
|
45
|
+
let runtime_block = build_runtime_status_block(
|
|
46
|
+
coordinator_running,
|
|
47
|
+
undelivered_backlog,
|
|
48
|
+
!tmux_present,
|
|
49
|
+
&freshness,
|
|
50
|
+
);
|
|
51
|
+
let agents = enrich_agents(state.get("agents"), tmux_present, &freshness);
|
|
52
|
+
let tasks = state.get("tasks").cloned().unwrap_or_else(|| json!([]));
|
|
53
|
+
let leader_receiver = state
|
|
54
|
+
.get("leader_receiver")
|
|
55
|
+
.cloned()
|
|
56
|
+
.unwrap_or_else(|| json!({}));
|
|
57
|
+
let is_external_leader = crate::state::projection::state_is_external_leader(state);
|
|
58
|
+
let leader_topology = if is_external_leader {
|
|
59
|
+
"external"
|
|
60
|
+
} else {
|
|
61
|
+
"managed"
|
|
62
|
+
};
|
|
63
|
+
let leader_attach_command = if is_external_leader {
|
|
64
|
+
None
|
|
65
|
+
} else {
|
|
66
|
+
let window_name = state
|
|
67
|
+
.pointer("/leader_receiver/window_name")
|
|
68
|
+
.and_then(Value::as_str)
|
|
69
|
+
.unwrap_or("leader");
|
|
70
|
+
session_name.as_str().and_then(|session| {
|
|
71
|
+
// Bug #7 (gate review §6): build the attach command from the
|
|
72
|
+
// SAME endpoint the readiness probe uses (state's persisted
|
|
73
|
+
// tmux_endpoint/tmux_socket), so the printed command matches
|
|
74
|
+
// where the session actually lives.
|
|
75
|
+
crate::tmux_backend::attach_command_for_runtime_state_or_workspace(
|
|
76
|
+
workspace,
|
|
77
|
+
Some(state),
|
|
78
|
+
&crate::transport::SessionName::new(session.to_string()),
|
|
79
|
+
window_name,
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
};
|
|
83
|
+
let mut readiness_state = state.clone();
|
|
84
|
+
if let Some(obj) = readiness_state.as_object_mut() {
|
|
85
|
+
obj.insert(
|
|
86
|
+
"tmux_session_present".to_string(),
|
|
87
|
+
serde_json::json!(tmux_present),
|
|
50
88
|
);
|
|
51
|
-
let agents = enrich_agents(state.get("agents"), tmux_present, &freshness);
|
|
52
|
-
let tasks = state
|
|
53
|
-
.get("tasks")
|
|
54
|
-
.cloned()
|
|
55
|
-
.unwrap_or_else(|| json!([]));
|
|
56
|
-
let leader_receiver = state
|
|
57
|
-
.get("leader_receiver")
|
|
58
|
-
.cloned()
|
|
59
|
-
.unwrap_or_else(|| json!({}));
|
|
60
|
-
let is_external_leader = crate::state::projection::state_is_external_leader(state);
|
|
61
|
-
let leader_topology = if is_external_leader { "external" } else { "managed" };
|
|
62
|
-
let leader_attach_command = if is_external_leader {
|
|
63
|
-
None
|
|
64
|
-
} else {
|
|
65
|
-
let window_name = state
|
|
66
|
-
.pointer("/leader_receiver/window_name")
|
|
67
|
-
.and_then(Value::as_str)
|
|
68
|
-
.unwrap_or("leader");
|
|
69
|
-
session_name.as_str().and_then(|session| {
|
|
70
|
-
// Bug #7 (gate review §6): build the attach command from the
|
|
71
|
-
// SAME endpoint the readiness probe uses (state's persisted
|
|
72
|
-
// tmux_endpoint/tmux_socket), so the printed command matches
|
|
73
|
-
// where the session actually lives.
|
|
74
|
-
crate::tmux_backend::attach_command_for_runtime_state_or_workspace(
|
|
75
|
-
workspace,
|
|
76
|
-
Some(state),
|
|
77
|
-
&crate::transport::SessionName::new(session.to_string()),
|
|
78
|
-
window_name,
|
|
79
|
-
)
|
|
80
|
-
})
|
|
81
|
-
};
|
|
82
|
-
let mut readiness_state = state.clone();
|
|
83
|
-
if let Some(obj) = readiness_state.as_object_mut() {
|
|
84
|
-
obj.insert("tmux_session_present".to_string(), serde_json::json!(tmux_present));
|
|
85
|
-
}
|
|
86
|
-
let readiness = crate::cli::diagnose::wait_readiness(&readiness_state);
|
|
87
|
-
let full = json!({
|
|
88
|
-
"ok": true,
|
|
89
|
-
"team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
|
|
90
|
-
"session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
|
|
91
|
-
"leader_topology": leader_topology,
|
|
92
|
-
"is_external_leader": is_external_leader,
|
|
93
|
-
"leader_attach_command": leader_attach_command,
|
|
94
|
-
"leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
|
|
95
|
-
"tmux_session_present": tmux_present,
|
|
96
|
-
"all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
|
|
97
|
-
"all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
|
|
98
|
-
"all_resumable_have_session": readiness.get("all_resumable_have_session").cloned().unwrap_or(Value::Bool(true)),
|
|
99
|
-
"session_capture_complete": readiness.get("session_capture_complete").cloned().unwrap_or(Value::Bool(true)),
|
|
100
|
-
"session_capture_incomplete": readiness.get("session_capture_incomplete").cloned().unwrap_or(Value::Bool(false)),
|
|
101
|
-
"incomplete_session_capture_agents": readiness.get("incomplete_session_capture_agents").cloned().unwrap_or_else(|| json!([])),
|
|
102
|
-
"pending_session_agent_ids": readiness.get("pending_session_agent_ids").cloned().unwrap_or_else(|| json!([])),
|
|
103
|
-
"leader_receiver": leader_receiver,
|
|
104
|
-
"teams": state.get("teams").cloned().unwrap_or_else(|| json!({})),
|
|
105
|
-
"agents": agents,
|
|
106
|
-
"agent_health": agent_health(&conn, owner_team_id)?,
|
|
107
|
-
"tasks": tasks,
|
|
108
|
-
"messages": message_counts(&conn, owner_team_id)?,
|
|
109
|
-
"queued_messages": queued_messages(&conn, owner_team_id, 8)?,
|
|
110
|
-
"pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
|
|
111
|
-
"results": result_counts(&conn, owner_team_id)?,
|
|
112
|
-
"latest_results": latest_result_summaries(&store, owner_team_id)?,
|
|
113
|
-
"readiness": readiness,
|
|
114
|
-
"coordinator": coordinator_health_value(health),
|
|
115
|
-
"runtime": runtime_block,
|
|
116
|
-
"reminder": crate::cli::STATUS_REMINDER,
|
|
117
|
-
"last_events": Value::Array(
|
|
118
|
-
crate::event_log::EventLog::new(workspace)
|
|
119
|
-
.tail(10)
|
|
120
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
121
|
-
),
|
|
122
|
-
});
|
|
123
|
-
if compact {
|
|
124
|
-
Ok(compact_status(full))
|
|
125
|
-
} else {
|
|
126
|
-
Ok(full)
|
|
127
|
-
}
|
|
128
89
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
90
|
+
let readiness = crate::cli::diagnose::wait_readiness(&readiness_state);
|
|
91
|
+
let full = json!({
|
|
92
|
+
"ok": true,
|
|
93
|
+
"team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
|
|
94
|
+
"session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
|
|
95
|
+
"leader_topology": leader_topology,
|
|
96
|
+
"is_external_leader": is_external_leader,
|
|
97
|
+
"leader_attach_command": leader_attach_command,
|
|
98
|
+
"leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
|
|
99
|
+
"tmux_session_present": tmux_present,
|
|
100
|
+
"all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
|
|
101
|
+
"all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
|
|
102
|
+
"all_resumable_have_session": readiness.get("all_resumable_have_session").cloned().unwrap_or(Value::Bool(true)),
|
|
103
|
+
"session_capture_complete": readiness.get("session_capture_complete").cloned().unwrap_or(Value::Bool(true)),
|
|
104
|
+
"session_capture_incomplete": readiness.get("session_capture_incomplete").cloned().unwrap_or(Value::Bool(false)),
|
|
105
|
+
"incomplete_session_capture_agents": readiness.get("incomplete_session_capture_agents").cloned().unwrap_or_else(|| json!([])),
|
|
106
|
+
"pending_session_agent_ids": readiness.get("pending_session_agent_ids").cloned().unwrap_or_else(|| json!([])),
|
|
107
|
+
"leader_receiver": leader_receiver,
|
|
108
|
+
"teams": state.get("teams").cloned().unwrap_or_else(|| json!({})),
|
|
109
|
+
"agents": agents,
|
|
110
|
+
"agent_health": agent_health(&conn, owner_team_id)?,
|
|
111
|
+
"tasks": tasks,
|
|
112
|
+
"messages": message_counts(&conn, owner_team_id)?,
|
|
113
|
+
"queued_messages": queued_messages(&conn, owner_team_id, 8)?,
|
|
114
|
+
"pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
|
|
115
|
+
"results": result_counts(&conn, owner_team_id)?,
|
|
116
|
+
"latest_results": latest_result_summaries(&store, owner_team_id)?,
|
|
117
|
+
"readiness": readiness,
|
|
118
|
+
"coordinator": coordinator_health_value(health),
|
|
119
|
+
"runtime": runtime_block,
|
|
120
|
+
"reminder": crate::cli::STATUS_REMINDER,
|
|
121
|
+
"last_events": Value::Array(
|
|
122
|
+
crate::event_log::EventLog::new(workspace)
|
|
123
|
+
.tail(10)
|
|
124
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
125
|
+
),
|
|
126
|
+
});
|
|
127
|
+
if compact {
|
|
128
|
+
Ok(compact_status(full))
|
|
129
|
+
} else {
|
|
130
|
+
Ok(full)
|
|
133
131
|
}
|
|
132
|
+
}
|
|
133
|
+
/// `status.format_status(workspace, agent)`(人读)。
|
|
134
|
+
pub fn format_status(workspace: &Path, agent: Option<&str>) -> Result<String, CliError> {
|
|
135
|
+
let state = read_runtime_state(workspace);
|
|
136
|
+
format_status_scoped(workspace, &state, None, agent)
|
|
137
|
+
}
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
None => {
|
|
149
|
-
let status = status_scoped(workspace, state, owner_team_id, false, false)?;
|
|
150
|
-
Ok(crate::cli::format_status_csv(&status))
|
|
151
|
-
}
|
|
139
|
+
pub fn format_status_scoped(
|
|
140
|
+
workspace: &Path,
|
|
141
|
+
state: &Value,
|
|
142
|
+
owner_team_id: Option<&str>,
|
|
143
|
+
agent: Option<&str>,
|
|
144
|
+
) -> Result<String, CliError> {
|
|
145
|
+
match agent {
|
|
146
|
+
// queries.py:130-162 — the agent branch renders the multi-line agent detail
|
|
147
|
+
// from the FULL status payload; an unknown agent id errors.
|
|
148
|
+
Some(agent) => {
|
|
149
|
+
let status = status_scoped(workspace, state, owner_team_id, false, false)?;
|
|
150
|
+
format_agent_status(workspace, &status, agent)
|
|
152
151
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
fn format_agent_status(
|
|
157
|
-
workspace: &Path,
|
|
158
|
-
status: &Value,
|
|
159
|
-
agent_id: &str,
|
|
160
|
-
) -> Result<String, CliError> {
|
|
161
|
-
let agents = status.get("agents").and_then(Value::as_object);
|
|
162
|
-
let health = status.get("agent_health").and_then(Value::as_object);
|
|
163
|
-
let known = agents.is_some_and(|map| map.contains_key(agent_id))
|
|
164
|
-
|| health.is_some_and(|map| map.contains_key(agent_id));
|
|
165
|
-
if !known {
|
|
166
|
-
return Err(CliError::Runtime(format!("unknown agent id: {agent_id}")));
|
|
152
|
+
None => {
|
|
153
|
+
let status = status_scoped(workspace, state, owner_team_id, false, false)?;
|
|
154
|
+
Ok(crate::cli::format_status_csv(&status))
|
|
167
155
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.and_then(|map| map.get(agent_id))
|
|
171
|
-
.unwrap_or(&empty);
|
|
172
|
-
let row = health.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
|
|
173
|
-
let status_text = row
|
|
174
|
-
.get("status")
|
|
175
|
-
.and_then(Value::as_str)
|
|
176
|
-
.map(str::to_string)
|
|
177
|
-
.unwrap_or_else(||
|
|
156
|
+
}
|
|
157
|
+
}
|
|
178
158
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
159
|
+
/// `format_status` agent 分支(`queries.py:135-162`)。
|
|
160
|
+
fn format_agent_status(
|
|
161
|
+
workspace: &Path,
|
|
162
|
+
status: &Value,
|
|
163
|
+
agent_id: &str,
|
|
164
|
+
) -> Result<String, CliError> {
|
|
165
|
+
let agents = status.get("agents").and_then(Value::as_object);
|
|
166
|
+
let health = status.get("agent_health").and_then(Value::as_object);
|
|
167
|
+
let known = agents.is_some_and(|map| map.contains_key(agent_id))
|
|
168
|
+
|| health.is_some_and(|map| map.contains_key(agent_id));
|
|
169
|
+
if !known {
|
|
170
|
+
return Err(CliError::Runtime(format!("unknown agent id: {agent_id}")));
|
|
171
|
+
}
|
|
172
|
+
let empty = json!({});
|
|
173
|
+
let agent = agents.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
|
|
174
|
+
let row = health.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
|
|
175
|
+
let status_text = row
|
|
176
|
+
.get("status")
|
|
177
|
+
.and_then(Value::as_str)
|
|
178
|
+
.map(str::to_string)
|
|
179
|
+
.unwrap_or_else(|| {
|
|
180
|
+
agent_health_status_text(agent.get("status").and_then(Value::as_str).unwrap_or(""))
|
|
181
|
+
});
|
|
182
|
+
let tasks = status
|
|
183
|
+
.get("tasks")
|
|
184
|
+
.and_then(Value::as_array)
|
|
185
|
+
.cloned()
|
|
186
|
+
.unwrap_or_default();
|
|
187
|
+
let task_id = current_task_for_agent(&tasks, agent_id).unwrap_or_else(|| "-".to_string());
|
|
188
|
+
let inbox_rows = crate::message_store::MessageStore::open(workspace)
|
|
189
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?
|
|
190
|
+
.inbox(agent_id, 3, None)
|
|
191
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
192
|
+
let mut lines = vec![
|
|
193
|
+
format!("{agent_id} {status_text}"),
|
|
194
|
+
format!(" provider: {}", py_get(agent, "provider")),
|
|
195
|
+
format!(" model: {}", py_get(agent, "model")),
|
|
196
|
+
format!(" profile: {}", py_get(agent, "profile")),
|
|
197
|
+
format!(" session_id: {}", py_get_or_dash(agent, "session_id")),
|
|
198
|
+
format!(" captured_via: {}", py_get_or_dash(agent, "captured_via")),
|
|
199
|
+
format!(
|
|
200
|
+
" attribution_confidence: {}",
|
|
201
|
+
py_get_or_dash(agent, "attribution_confidence")
|
|
202
|
+
),
|
|
203
|
+
format!(" task: {task_id}"),
|
|
204
|
+
format!(" handoff: {}", py_get(agent, "handoff_path")),
|
|
205
|
+
" recent messages:".to_string(),
|
|
206
|
+
];
|
|
207
|
+
if inbox_rows.is_empty() {
|
|
208
|
+
lines.push(" none".to_string());
|
|
209
|
+
} else {
|
|
210
|
+
for item in &inbox_rows {
|
|
211
|
+
let content = item.get("content").and_then(Value::as_str).unwrap_or("");
|
|
212
|
+
let content: String = content.chars().take(120).collect();
|
|
213
|
+
lines.push(format!(
|
|
214
|
+
" {} {} -> {} {}: {content}",
|
|
215
|
+
py_get_or_dash(item, "created_at"),
|
|
216
|
+
py_get_or_dash(item, "sender"),
|
|
217
|
+
py_get_or_dash(item, "recipient"),
|
|
218
|
+
py_get_or_dash(item, "status"),
|
|
219
|
+
));
|
|
216
220
|
}
|
|
217
|
-
Ok(lines.join("\n"))
|
|
218
221
|
}
|
|
222
|
+
Ok(lines.join("\n"))
|
|
223
|
+
}
|
|
219
224
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
/// `current_task_for_agent`(`approvals/status.py:127-132`)。
|
|
226
|
+
fn current_task_for_agent(tasks: &[Value], agent_id: &str) -> Option<String> {
|
|
227
|
+
const ACTIVE: [&str; 5] = ["pending", "ready", "running", "blocked", "needs_retry"];
|
|
228
|
+
for task in tasks.iter().rev() {
|
|
229
|
+
let assignee = task.get("assignee").and_then(Value::as_str);
|
|
230
|
+
let status = task
|
|
231
|
+
.get("status")
|
|
232
|
+
.and_then(Value::as_str)
|
|
233
|
+
.unwrap_or("pending");
|
|
234
|
+
if assignee == Some(agent_id) && ACTIVE.contains(&status) {
|
|
235
|
+
return task.get("id").and_then(Value::as_str).map(str::to_string);
|
|
229
236
|
}
|
|
230
|
-
None
|
|
231
237
|
}
|
|
238
|
+
None
|
|
239
|
+
}
|
|
232
240
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
241
|
+
fn agent_health_status_text(status: &str) -> String {
|
|
242
|
+
serde_json::to_value(crate::provider::agent_health_status(status))
|
|
243
|
+
.ok()
|
|
244
|
+
.and_then(|v| v.as_str().map(str::to_string))
|
|
245
|
+
.unwrap_or_else(|| "-".to_string())
|
|
246
|
+
}
|
|
239
247
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
+
/// Python `agent.get(key, '-')`:键缺失 → `-`;键存在但为 null → 打印 `None`。
|
|
249
|
+
fn py_get(agent: &Value, key: &str) -> String {
|
|
250
|
+
match agent.get(key) {
|
|
251
|
+
None => "-".to_string(),
|
|
252
|
+
Some(Value::Null) => "None".to_string(),
|
|
253
|
+
Some(Value::String(s)) => s.clone(),
|
|
254
|
+
Some(other) => other.to_string(),
|
|
248
255
|
}
|
|
256
|
+
}
|
|
249
257
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
258
|
+
/// Python `agent.get(key) or '-'`:缺失/null/空串都落 `-`。
|
|
259
|
+
fn py_get_or_dash(agent: &Value, key: &str) -> String {
|
|
260
|
+
match agent.get(key) {
|
|
261
|
+
Some(Value::String(s)) if !s.is_empty() => s.clone(),
|
|
262
|
+
Some(Value::Number(n)) => n.to_string(),
|
|
263
|
+
_ => "-".to_string(),
|
|
257
264
|
}
|
|
265
|
+
}
|
|
258
266
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
267
|
+
/// `latest_result_summaries`(`queries.py:83-89`)。
|
|
268
|
+
fn latest_result_summaries(
|
|
269
|
+
store: &crate::message_store::MessageStore,
|
|
270
|
+
owner_team_id: Option<&str>,
|
|
271
|
+
) -> Result<Value, CliError> {
|
|
272
|
+
let rows = store
|
|
273
|
+
.latest_results(5, owner_team_id)
|
|
274
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
275
|
+
Ok(Value::Array(
|
|
276
|
+
rows.iter()
|
|
277
|
+
.filter_map(crate::message_store::result_summary_from_row)
|
|
278
|
+
.collect(),
|
|
279
|
+
))
|
|
280
|
+
}
|
|
281
|
+
/// `status.approvals(workspace, agent_id)`(JSON)/`format_approvals`(人读)。
|
|
282
|
+
pub fn approvals(workspace: &Path, agent: Option<&str>, as_json: bool) -> Result<Value, CliError> {
|
|
283
|
+
let _ = as_json;
|
|
284
|
+
let state = read_runtime_state(workspace);
|
|
285
|
+
approvals_scoped(workspace, &state, agent, as_json)
|
|
286
|
+
}
|
|
279
287
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
288
|
+
pub fn approvals_scoped(
|
|
289
|
+
workspace: &Path,
|
|
290
|
+
state: &Value,
|
|
291
|
+
agent: Option<&str>,
|
|
292
|
+
as_json: bool,
|
|
293
|
+
) -> Result<Value, CliError> {
|
|
294
|
+
let _ = as_json;
|
|
295
|
+
let session = state
|
|
296
|
+
.get("session_name")
|
|
297
|
+
.and_then(Value::as_str)
|
|
298
|
+
.filter(|s| !s.is_empty());
|
|
299
|
+
let mut approvals = Vec::new();
|
|
300
|
+
if let (Some(session), Some(agents)) = (session, state.get("agents").and_then(Value::as_object))
|
|
301
|
+
{
|
|
302
|
+
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
303
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
304
|
+
// 0.5.x Phase 1d Batch 3: use the factory-resolved backend
|
|
305
|
+
// so conpty teams get their scrollback from the shim rather
|
|
306
|
+
// than a fake tmux capture that always returns empty. Tmux
|
|
307
|
+
// teams take the same code path as before (byte-equivalent).
|
|
308
|
+
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
309
|
+
&run_ws,
|
|
310
|
+
Some(state),
|
|
311
|
+
crate::transport_factory::TransportPurpose::Status,
|
|
312
|
+
);
|
|
313
|
+
let backend: Box<dyn crate::transport::Transport> = match resolved {
|
|
314
|
+
Ok(r) => r.backend,
|
|
315
|
+
Err(_) => {
|
|
316
|
+
// Read-path fallback: refused factory means we don't
|
|
317
|
+
// try to inspect approval prompts. Empty vec = no
|
|
318
|
+
// waiting approvals, honest.
|
|
319
|
+
return Ok(json!({
|
|
320
|
+
"ok": true,
|
|
321
|
+
"waiting": false,
|
|
322
|
+
"waiting_count": 0,
|
|
323
|
+
"approvals": [],
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
for (agent_id, agent_state) in agents {
|
|
328
|
+
if agent.is_some_and(|wanted| wanted != agent_id) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
let window = agent_window(agent_id, agent_state);
|
|
332
|
+
let target = crate::transport::Target::SessionWindow {
|
|
333
|
+
session: crate::transport::SessionName::new(session.to_string()),
|
|
334
|
+
window: crate::transport::WindowName::new(window.clone()),
|
|
314
335
|
};
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
window: crate::transport::WindowName::new(window.clone()),
|
|
323
|
-
};
|
|
324
|
-
let Ok(captured) = backend.capture(&target, crate::transport::CaptureRange::Tail(120)) else {
|
|
325
|
-
continue;
|
|
326
|
-
};
|
|
327
|
-
if let Some(prompt) = crate::provider::extract_approval_prompt(agent_id, &captured.text) {
|
|
328
|
-
approvals.push(prompt.to_ordered_value());
|
|
329
|
-
}
|
|
336
|
+
let Ok(captured) = backend.capture(&target, crate::transport::CaptureRange::Tail(120))
|
|
337
|
+
else {
|
|
338
|
+
continue;
|
|
339
|
+
};
|
|
340
|
+
if let Some(prompt) = crate::provider::extract_approval_prompt(agent_id, &captured.text)
|
|
341
|
+
{
|
|
342
|
+
approvals.push(prompt.to_ordered_value());
|
|
330
343
|
}
|
|
331
344
|
}
|
|
332
|
-
let waiting_count = approvals.len();
|
|
333
|
-
Ok(json!({
|
|
334
|
-
"ok": true,
|
|
335
|
-
"waiting": waiting_count > 0,
|
|
336
|
-
"waiting_count": waiting_count,
|
|
337
|
-
"approvals": approvals,
|
|
338
|
-
"scan": {
|
|
339
|
-
"mode": "tail",
|
|
340
|
-
"lines": 120,
|
|
341
|
-
"raw_output": false,
|
|
342
|
-
},
|
|
343
|
-
}))
|
|
344
345
|
}
|
|
346
|
+
let waiting_count = approvals.len();
|
|
347
|
+
Ok(json!({
|
|
348
|
+
"ok": true,
|
|
349
|
+
"waiting": waiting_count > 0,
|
|
350
|
+
"waiting_count": waiting_count,
|
|
351
|
+
"approvals": approvals,
|
|
352
|
+
"scan": {
|
|
353
|
+
"mode": "tail",
|
|
354
|
+
"lines": 120,
|
|
355
|
+
"raw_output": false,
|
|
356
|
+
},
|
|
357
|
+
}))
|
|
358
|
+
}
|
|
345
359
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
approvals
|
|
356
|
-
.iter()
|
|
357
|
-
.map(|approval| {
|
|
358
|
-
let agent = approval.get("agent_id").and_then(Value::as_str).unwrap_or("-");
|
|
359
|
-
let kind = approval.get("kind").and_then(Value::as_str).unwrap_or("unknown");
|
|
360
|
-
let prompt = approval
|
|
361
|
-
.get("prompt")
|
|
362
|
-
.and_then(Value::as_str)
|
|
363
|
-
.or_else(|| approval.get("subject").and_then(Value::as_str))
|
|
364
|
-
.unwrap_or("-");
|
|
365
|
-
format!("{agent}: {kind} {prompt}")
|
|
366
|
-
})
|
|
367
|
-
.collect::<Vec<_>>()
|
|
368
|
-
.join("\n")
|
|
360
|
+
pub fn format_approvals(value: &Value) -> String {
|
|
361
|
+
let approvals = value
|
|
362
|
+
.get("approvals")
|
|
363
|
+
.and_then(Value::as_array)
|
|
364
|
+
.map(Vec::as_slice)
|
|
365
|
+
.unwrap_or(&[]);
|
|
366
|
+
if approvals.is_empty() {
|
|
367
|
+
return "No pending approvals.".to_string();
|
|
369
368
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
369
|
+
approvals
|
|
370
|
+
.iter()
|
|
371
|
+
.map(|approval| {
|
|
372
|
+
let agent = approval
|
|
373
|
+
.get("agent_id")
|
|
374
|
+
.and_then(Value::as_str)
|
|
375
|
+
.unwrap_or("-");
|
|
376
|
+
let kind = approval
|
|
377
|
+
.get("kind")
|
|
378
|
+
.and_then(Value::as_str)
|
|
379
|
+
.unwrap_or("unknown");
|
|
380
|
+
let prompt = approval
|
|
381
|
+
.get("prompt")
|
|
382
|
+
.and_then(Value::as_str)
|
|
383
|
+
.or_else(|| approval.get("subject").and_then(Value::as_str))
|
|
384
|
+
.unwrap_or("-");
|
|
385
|
+
format!("{agent}: {kind} {prompt}")
|
|
386
|
+
})
|
|
387
|
+
.collect::<Vec<_>>()
|
|
388
|
+
.join("\n")
|
|
389
|
+
}
|
|
390
|
+
/// `status.inbox(workspace, agent, limit, since)`(JSON)/`format_inbox`(人读)。
|
|
391
|
+
pub fn inbox(
|
|
392
|
+
workspace: &Path,
|
|
393
|
+
agent: &str,
|
|
394
|
+
limit: usize,
|
|
395
|
+
since: Option<&str>,
|
|
396
|
+
as_json: bool,
|
|
397
|
+
owner_team_id: Option<&str>,
|
|
398
|
+
) -> Result<Value, CliError> {
|
|
399
|
+
let _ = as_json;
|
|
400
|
+
let store = crate::message_store::MessageStore::open(workspace)
|
|
401
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
402
|
+
let mut messages = store
|
|
403
|
+
.inbox(agent, limit, owner_team_id)
|
|
404
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
405
|
+
if let Some(cutoff) = since.and_then(parse_rfc3339) {
|
|
406
|
+
messages.retain(|message| {
|
|
407
|
+
message
|
|
408
|
+
.get("created_at")
|
|
409
|
+
.and_then(Value::as_str)
|
|
410
|
+
.and_then(parse_rfc3339)
|
|
411
|
+
.is_some_and(|created| created >= cutoff)
|
|
412
|
+
});
|
|
400
413
|
}
|
|
414
|
+
Ok(json!({
|
|
415
|
+
"ok": true,
|
|
416
|
+
"agent_id": agent,
|
|
417
|
+
"messages": messages,
|
|
418
|
+
"since": since,
|
|
419
|
+
}))
|
|
420
|
+
}
|
|
401
421
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
422
|
+
fn parse_rfc3339(value: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
|
423
|
+
chrono::DateTime::parse_from_rfc3339(value).ok()
|
|
424
|
+
}
|
|
405
425
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
426
|
+
fn read_runtime_state(workspace: &Path) -> Value {
|
|
427
|
+
let path = workspace.join(".team").join("runtime").join("state.json");
|
|
428
|
+
std::fs::read_to_string(path)
|
|
429
|
+
.ok()
|
|
430
|
+
.and_then(|s| serde_json::from_str(&s).ok())
|
|
431
|
+
.unwrap_or_else(|| json!({}))
|
|
432
|
+
}
|
|
413
433
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
434
|
+
fn resolve_status_owner_team(
|
|
435
|
+
workspace: &Path,
|
|
436
|
+
owner_team_id: Option<&str>,
|
|
437
|
+
) -> Result<Option<String>, CliError> {
|
|
438
|
+
let Some(requested) = owner_team_id.filter(|team| !team.is_empty()) else {
|
|
439
|
+
return Ok(None);
|
|
440
|
+
};
|
|
441
|
+
let state = read_runtime_state(workspace);
|
|
442
|
+
match crate::state::projection::resolve_owner_team_id(&state, requested) {
|
|
443
|
+
OwnerTeamResolution::Canonical(canonical) => Ok(Some(canonical)),
|
|
444
|
+
OwnerTeamResolution::LegacyAlias {
|
|
445
|
+
requested,
|
|
446
|
+
canonical,
|
|
447
|
+
} => {
|
|
448
|
+
let log = crate::event_log::EventLog::new(workspace);
|
|
449
|
+
crate::messaging::delivery::normalize_owner_team_id_rows(
|
|
450
|
+
workspace,
|
|
451
|
+
&requested,
|
|
452
|
+
&canonical,
|
|
453
|
+
None,
|
|
454
|
+
Some(&log),
|
|
455
|
+
)
|
|
456
|
+
.map_err(CliError::from)?;
|
|
457
|
+
Ok(Some(canonical))
|
|
437
458
|
}
|
|
459
|
+
OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => Ok(None),
|
|
438
460
|
}
|
|
461
|
+
}
|
|
439
462
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
463
|
+
fn agent_window(agent_id: &str, agent_state: &Value) -> String {
|
|
464
|
+
["window", "window_name"]
|
|
465
|
+
.iter()
|
|
466
|
+
.find_map(|key| {
|
|
467
|
+
agent_state
|
|
468
|
+
.get(*key)
|
|
469
|
+
.and_then(Value::as_str)
|
|
470
|
+
.filter(|s| !s.is_empty())
|
|
471
|
+
})
|
|
472
|
+
.unwrap_or(agent_id)
|
|
473
|
+
.to_string()
|
|
474
|
+
}
|
|
447
475
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
476
|
+
/// 0.5.41 Slice 3 (fault-invisibility-locate.md §5): single-source
|
|
477
|
+
/// runtime freshness projection consumed by status/diagnose. Read-
|
|
478
|
+
/// only over existing sources (`coordinator_health`, heartbeat
|
|
479
|
+
/// sidecar, watch state); computes NO transport calls of its own.
|
|
480
|
+
#[derive(Default, Clone)]
|
|
481
|
+
pub(crate) struct RuntimeFreshness {
|
|
482
|
+
pub coordinator_service_available: bool,
|
|
483
|
+
pub host_boot_stale: bool,
|
|
484
|
+
pub host_boot_recorded: Option<String>,
|
|
485
|
+
pub host_boot_current: Option<String>,
|
|
486
|
+
pub provider_exited_agents: std::collections::BTreeSet<String>,
|
|
487
|
+
}
|
|
460
488
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
489
|
+
impl RuntimeFreshness {
|
|
490
|
+
fn host_boot_stale_reason(&self) -> Option<&'static str> {
|
|
491
|
+
self.host_boot_stale.then_some("host_boot_mismatch")
|
|
465
492
|
}
|
|
493
|
+
}
|
|
466
494
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|| entry.get("provider_exit_marker").is_some();
|
|
510
|
-
if exited {
|
|
511
|
-
provider_exited_agents.insert(agent_id.clone());
|
|
512
|
-
}
|
|
495
|
+
pub(crate) fn compute_runtime_freshness(
|
|
496
|
+
workspace: &Path,
|
|
497
|
+
state: &Value,
|
|
498
|
+
health: &crate::coordinator::HealthReport,
|
|
499
|
+
) -> RuntimeFreshness {
|
|
500
|
+
let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
|
|
501
|
+
let heartbeat = crate::coordinator::read_coordinator_heartbeat(&workspace_path);
|
|
502
|
+
let host_boot_recorded = heartbeat
|
|
503
|
+
.as_ref()
|
|
504
|
+
.and_then(|hb| hb.get("host_boot_id"))
|
|
505
|
+
.and_then(Value::as_str)
|
|
506
|
+
.filter(|s| !s.is_empty() && *s != "unknown")
|
|
507
|
+
.map(str::to_string);
|
|
508
|
+
let host_boot_current = crate::coordinator::probe_host_boot_id();
|
|
509
|
+
let host_boot_stale = match (host_boot_recorded.as_deref(), host_boot_current.as_deref()) {
|
|
510
|
+
(Some(recorded), Some(current)) => recorded != current,
|
|
511
|
+
_ => false,
|
|
512
|
+
};
|
|
513
|
+
// Collect worker-provider-exited agents from the coordinator
|
|
514
|
+
// abnormal_exit_watch payload (0.5.41 Slice 4 writes
|
|
515
|
+
// `worker_provider_exited` / `provider_process_dead=true`
|
|
516
|
+
// there). Read from top-level `coordinator.abnormal_exit_watch`
|
|
517
|
+
// OR the team-scoped mirror `teams.<key>.coordinator...`.
|
|
518
|
+
let mut provider_exited_agents = std::collections::BTreeSet::new();
|
|
519
|
+
for path in [
|
|
520
|
+
"/coordinator/abnormal_exit_watch",
|
|
521
|
+
&format!(
|
|
522
|
+
"/teams/{}/coordinator/abnormal_exit_watch",
|
|
523
|
+
state
|
|
524
|
+
.get("active_team_key")
|
|
525
|
+
.and_then(Value::as_str)
|
|
526
|
+
.unwrap_or("")
|
|
527
|
+
),
|
|
528
|
+
] {
|
|
529
|
+
if let Some(watch) = state.pointer(path).and_then(Value::as_object) {
|
|
530
|
+
for (agent_id, entry) in watch {
|
|
531
|
+
let exited = entry.get("worker_provider_exited").and_then(Value::as_bool)
|
|
532
|
+
== Some(true)
|
|
533
|
+
|| entry.get("provider_process_dead").and_then(Value::as_bool) == Some(true)
|
|
534
|
+
|| entry.get("provider_exit_marker").is_some();
|
|
535
|
+
if exited {
|
|
536
|
+
provider_exited_agents.insert(agent_id.clone());
|
|
513
537
|
}
|
|
514
538
|
}
|
|
515
539
|
}
|
|
516
|
-
RuntimeFreshness {
|
|
517
|
-
coordinator_service_available: health.service_available,
|
|
518
|
-
host_boot_stale,
|
|
519
|
-
host_boot_recorded,
|
|
520
|
-
host_boot_current,
|
|
521
|
-
provider_exited_agents,
|
|
522
|
-
}
|
|
523
540
|
}
|
|
541
|
+
RuntimeFreshness {
|
|
542
|
+
coordinator_service_available: health.service_available,
|
|
543
|
+
host_boot_stale,
|
|
544
|
+
host_boot_recorded,
|
|
545
|
+
host_boot_current,
|
|
546
|
+
provider_exited_agents,
|
|
547
|
+
}
|
|
548
|
+
}
|
|
524
549
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
550
|
+
fn enrich_agents(
|
|
551
|
+
agents: Option<&Value>,
|
|
552
|
+
tmux_session_present: bool,
|
|
553
|
+
freshness: &RuntimeFreshness,
|
|
554
|
+
) -> Value {
|
|
555
|
+
let Some(Value::Object(input)) = agents else {
|
|
556
|
+
return json!({});
|
|
557
|
+
};
|
|
558
|
+
let mut out = Map::new();
|
|
559
|
+
for (agent_id, value) in input {
|
|
560
|
+
match value {
|
|
561
|
+
Value::Object(obj) => {
|
|
562
|
+
let mut enriched = obj.clone();
|
|
563
|
+
enriched.insert(
|
|
564
|
+
"interacted".to_string(),
|
|
565
|
+
Value::String(interacted_marker(obj.get("first_send_at"))),
|
|
566
|
+
);
|
|
567
|
+
// 0.5.41 Slice 3: order stale sources most-authoritative-first.
|
|
568
|
+
// Host boot mismatch wins because it invalidates all cached
|
|
569
|
+
// pane/pid/session facts. Provider-exit marker wins over
|
|
570
|
+
// pane liveness because the wrapper leaves an interactive
|
|
571
|
+
// shell live. Coordinator unavailability without stronger
|
|
572
|
+
// live provider proof means DB agent_health rows are stale.
|
|
573
|
+
// Tmux session missing keeps its existing legacy path so
|
|
574
|
+
// pre-0.5.41 tests remain byte-identical when no new
|
|
575
|
+
// signal fires.
|
|
576
|
+
let has_pane_binding = agent_has_pane_fact(&Value::Object(obj.clone()));
|
|
577
|
+
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5 point 3
|
|
578
|
+
// + §9 RED4 live-provider guard): the wrapper-era pane
|
|
579
|
+
// liveness cannot prove provider liveness — but a state
|
|
580
|
+
// `pane_current_command` that MATCHES the agent's provider
|
|
581
|
+
// IS positive proof (the abnormal.rs classifier writes it
|
|
582
|
+
// when the pane's foreground command is the provider CLI).
|
|
583
|
+
// When that positive proof is present, no stale downgrade
|
|
584
|
+
// fires here so the agent renders as working.
|
|
585
|
+
let provider_command_positive_proof = provider_current_command_matches(obj);
|
|
586
|
+
// 0.5.41 Slice 3 (0.5.35 R4 regression guard): when the
|
|
587
|
+
// runtime classifier has already written canonical
|
|
588
|
+
// `worker_state=UNKNOWN` / `activity.status=uncertain`,
|
|
589
|
+
// that is the authoritative honest observation — do NOT
|
|
590
|
+
// reclassify it as `coordinator_unavailable` stale (which
|
|
591
|
+
// would land it in the Stopped bucket instead of Unknown).
|
|
592
|
+
// Host-boot mismatch and provider-exited marker are
|
|
593
|
+
// stronger, more specific signals and still win.
|
|
594
|
+
let canonical_unknown = agent_canonical_worker_state_is_unknown(obj);
|
|
595
|
+
let new_reason = if freshness.host_boot_stale && has_pane_binding {
|
|
596
|
+
freshness.host_boot_stale_reason()
|
|
597
|
+
} else if freshness.provider_exited_agents.contains(agent_id) {
|
|
598
|
+
Some("worker_provider_exited")
|
|
599
|
+
} else if !freshness.coordinator_service_available
|
|
600
|
+
&& has_pane_binding
|
|
601
|
+
&& !provider_command_positive_proof
|
|
602
|
+
&& !canonical_unknown
|
|
603
|
+
{
|
|
604
|
+
Some("coordinator_unavailable")
|
|
605
|
+
} else {
|
|
606
|
+
None
|
|
607
|
+
};
|
|
608
|
+
let legacy_reason = if provider_command_positive_proof {
|
|
609
|
+
None
|
|
610
|
+
} else {
|
|
611
|
+
stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
|
|
612
|
+
};
|
|
613
|
+
let reason = new_reason.or(legacy_reason);
|
|
614
|
+
if let Some(reason) = reason {
|
|
615
|
+
enriched.insert("stale".to_string(), Value::Bool(true));
|
|
538
616
|
enriched.insert(
|
|
539
|
-
"
|
|
540
|
-
Value::String(
|
|
617
|
+
"stale_reason".to_string(),
|
|
618
|
+
Value::String(reason.to_string()),
|
|
541
619
|
);
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
// + §9 RED4 live-provider guard): the wrapper-era pane
|
|
554
|
-
// liveness cannot prove provider liveness — but a state
|
|
555
|
-
// `pane_current_command` that MATCHES the agent's provider
|
|
556
|
-
// IS positive proof (the abnormal.rs classifier writes it
|
|
557
|
-
// when the pane's foreground command is the provider CLI).
|
|
558
|
-
// When that positive proof is present, no stale downgrade
|
|
559
|
-
// fires here so the agent renders as working.
|
|
560
|
-
let provider_command_positive_proof = provider_current_command_matches(obj);
|
|
561
|
-
// 0.5.41 Slice 3 (0.5.35 R4 regression guard): when the
|
|
562
|
-
// runtime classifier has already written canonical
|
|
563
|
-
// `worker_state=UNKNOWN` / `activity.status=uncertain`,
|
|
564
|
-
// that is the authoritative honest observation — do NOT
|
|
565
|
-
// reclassify it as `coordinator_unavailable` stale (which
|
|
566
|
-
// would land it in the Stopped bucket instead of Unknown).
|
|
567
|
-
// Host-boot mismatch and provider-exited marker are
|
|
568
|
-
// stronger, more specific signals and still win.
|
|
569
|
-
let canonical_unknown = agent_canonical_worker_state_is_unknown(obj);
|
|
570
|
-
let new_reason = if freshness.host_boot_stale && has_pane_binding {
|
|
571
|
-
freshness.host_boot_stale_reason()
|
|
572
|
-
} else if freshness.provider_exited_agents.contains(agent_id) {
|
|
573
|
-
Some("worker_provider_exited")
|
|
574
|
-
} else if !freshness.coordinator_service_available
|
|
575
|
-
&& has_pane_binding
|
|
576
|
-
&& !provider_command_positive_proof
|
|
577
|
-
&& !canonical_unknown
|
|
578
|
-
{
|
|
579
|
-
Some("coordinator_unavailable")
|
|
580
|
-
} else {
|
|
581
|
-
None
|
|
582
|
-
};
|
|
583
|
-
let legacy_reason = if provider_command_positive_proof {
|
|
584
|
-
None
|
|
585
|
-
} else {
|
|
586
|
-
stale_reason_for_agent(
|
|
587
|
-
&Value::Object(obj.clone()),
|
|
588
|
-
tmux_session_present,
|
|
589
|
-
)
|
|
590
|
-
};
|
|
591
|
-
let reason = new_reason.or(legacy_reason);
|
|
592
|
-
if let Some(reason) = reason {
|
|
593
|
-
enriched.insert("stale".to_string(), Value::Bool(true));
|
|
594
|
-
enriched.insert(
|
|
595
|
-
"stale_reason".to_string(),
|
|
596
|
-
Value::String(reason.to_string()),
|
|
597
|
-
);
|
|
598
|
-
// Downgrade cached BUSY/working when the stale source is
|
|
599
|
-
// one of the new authoritative signals OR the pre-existing
|
|
600
|
-
// session-missing signal. Legacy code only downgraded on
|
|
601
|
-
// !tmux_session_present; that let host_boot / provider-
|
|
602
|
-
// exit / coord-unavailable stale rows keep raw=running.
|
|
603
|
-
let is_new_signal = matches!(
|
|
604
|
-
reason,
|
|
605
|
-
"host_boot_mismatch"
|
|
606
|
-
| "worker_provider_exited"
|
|
607
|
-
| "coordinator_unavailable"
|
|
608
|
-
);
|
|
609
|
-
if !tmux_session_present || is_new_signal {
|
|
610
|
-
downgrade_stale_agent(&mut enriched);
|
|
611
|
-
}
|
|
620
|
+
// Downgrade cached BUSY/working when the stale source is
|
|
621
|
+
// one of the new authoritative signals OR the pre-existing
|
|
622
|
+
// session-missing signal. Legacy code only downgraded on
|
|
623
|
+
// !tmux_session_present; that let host_boot / provider-
|
|
624
|
+
// exit / coord-unavailable stale rows keep raw=running.
|
|
625
|
+
let is_new_signal = matches!(
|
|
626
|
+
reason,
|
|
627
|
+
"host_boot_mismatch" | "worker_provider_exited" | "coordinator_unavailable"
|
|
628
|
+
);
|
|
629
|
+
if !tmux_session_present || is_new_signal {
|
|
630
|
+
downgrade_stale_agent(&mut enriched);
|
|
612
631
|
}
|
|
613
|
-
out.insert(agent_id.clone(), Value::Object(enriched));
|
|
614
|
-
}
|
|
615
|
-
_ => {
|
|
616
|
-
out.insert(agent_id.clone(), value.clone());
|
|
617
632
|
}
|
|
633
|
+
out.insert(agent_id.clone(), Value::Object(enriched));
|
|
634
|
+
}
|
|
635
|
+
_ => {
|
|
636
|
+
out.insert(agent_id.clone(), value.clone());
|
|
618
637
|
}
|
|
619
638
|
}
|
|
620
|
-
Value::Object(out)
|
|
621
639
|
}
|
|
640
|
+
Value::Object(out)
|
|
641
|
+
}
|
|
622
642
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
}
|
|
632
|
-
let worker_state = agent
|
|
633
|
-
.get("worker_state")
|
|
634
|
-
.and_then(Value::as_str)
|
|
635
|
-
.unwrap_or("")
|
|
636
|
-
.to_ascii_uppercase();
|
|
637
|
-
if matches!(worker_state.as_str(), "RUNNING" | "BUSY" | "PROBABLY_IDLE") {
|
|
638
|
-
agent.insert("worker_state".to_string(), Value::String("DEAD".to_string()));
|
|
639
|
-
}
|
|
643
|
+
fn downgrade_stale_agent(agent: &mut Map<String, Value>) {
|
|
644
|
+
let raw = agent
|
|
645
|
+
.get("status")
|
|
646
|
+
.and_then(Value::as_str)
|
|
647
|
+
.unwrap_or("")
|
|
648
|
+
.to_ascii_lowercase();
|
|
649
|
+
if matches!(raw.as_str(), "running" | "busy" | "working" | "idle") {
|
|
650
|
+
agent.insert("status".to_string(), Value::String("stopped".to_string()));
|
|
640
651
|
}
|
|
652
|
+
let worker_state = agent
|
|
653
|
+
.get("worker_state")
|
|
654
|
+
.and_then(Value::as_str)
|
|
655
|
+
.unwrap_or("")
|
|
656
|
+
.to_ascii_uppercase();
|
|
657
|
+
if matches!(worker_state.as_str(), "RUNNING" | "BUSY" | "PROBABLY_IDLE") {
|
|
658
|
+
agent.insert(
|
|
659
|
+
"worker_state".to_string(),
|
|
660
|
+
Value::String("DEAD".to_string()),
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
641
664
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
665
|
+
fn stale_reason_for_agent(agent: &Value, tmux_session_present: bool) -> Option<&'static str> {
|
|
666
|
+
let pane_dead = !tmux_session_present && agent_has_pane_fact(agent);
|
|
667
|
+
let process_dead =
|
|
668
|
+
agent_process_dead(agent) || (!tmux_session_present && agent_has_process_fact(agent));
|
|
669
|
+
match (pane_dead, process_dead) {
|
|
670
|
+
(true, true) => Some("both"),
|
|
671
|
+
(false, true) => Some("process_dead"),
|
|
672
|
+
(true, false) => Some("pane_dead"),
|
|
673
|
+
(false, false) => None,
|
|
652
674
|
}
|
|
675
|
+
}
|
|
653
676
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
677
|
+
/// 0.5.41 Slice 3 (0.5.35 R4 regression guard): true when the agent
|
|
678
|
+
/// row carries the canonical `worker_state=UNKNOWN` OR
|
|
679
|
+
/// `activity.status=uncertain` observation the runtime classifier
|
|
680
|
+
/// writes. Used to skip the coordinator-unavailable stale mark
|
|
681
|
+
/// (see `enrich_agents`) so the pre-existing R4 rendering (UNKNOWN
|
|
682
|
+
/// beats WORKING) is preserved.
|
|
683
|
+
fn agent_canonical_worker_state_is_unknown(agent: &serde_json::Map<String, Value>) -> bool {
|
|
684
|
+
let worker_state_unknown = agent
|
|
685
|
+
.get("worker_state")
|
|
686
|
+
.and_then(Value::as_str)
|
|
687
|
+
.is_some_and(|value| value.eq_ignore_ascii_case("UNKNOWN"));
|
|
688
|
+
let activity_uncertain = agent
|
|
689
|
+
.get("activity")
|
|
690
|
+
.and_then(|v| v.get("status"))
|
|
691
|
+
.and_then(Value::as_str)
|
|
692
|
+
.is_some_and(|value| value.eq_ignore_ascii_case("uncertain"));
|
|
693
|
+
worker_state_unknown || activity_uncertain
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/// 0.5.41 Slice 3 (fault-invisibility-locate.md §9 RED4 live-provider
|
|
697
|
+
/// guard): true when the agent row carries a `pane_current_command`
|
|
698
|
+
/// that matches the agent's provider CLI. This is positive proof
|
|
699
|
+
/// the provider is the pane's foreground process — the abnormal.rs
|
|
700
|
+
/// classifier writes this field after the marker/current-command
|
|
701
|
+
/// check clears. When true, stale-downgrade paths in
|
|
702
|
+
/// `enrich_agents` skip so the row keeps its BUSY/working state.
|
|
703
|
+
fn provider_current_command_matches(agent: &serde_json::Map<String, Value>) -> bool {
|
|
704
|
+
let Some(command) = agent
|
|
705
|
+
.get("pane_current_command")
|
|
706
|
+
.and_then(Value::as_str)
|
|
707
|
+
.filter(|s| !s.is_empty())
|
|
708
|
+
else {
|
|
709
|
+
return false;
|
|
710
|
+
};
|
|
711
|
+
let Some(provider_wire) = agent.get("provider").and_then(Value::as_str) else {
|
|
712
|
+
return false;
|
|
713
|
+
};
|
|
714
|
+
let Some(provider) = crate::provider::wire::parse_provider(provider_wire) else {
|
|
715
|
+
return false;
|
|
716
|
+
};
|
|
717
|
+
crate::leader::command_matches_provider(provider, command)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
fn agent_has_pane_fact(agent: &Value) -> bool {
|
|
721
|
+
["pane_id", "window", "window_name"].iter().any(|key| {
|
|
722
|
+
agent
|
|
723
|
+
.get(*key)
|
|
668
724
|
.and_then(Value::as_str)
|
|
669
|
-
.is_some_and(|value| value.
|
|
670
|
-
|
|
671
|
-
|
|
725
|
+
.is_some_and(|value| !value.is_empty())
|
|
726
|
+
})
|
|
727
|
+
}
|
|
672
728
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
.get("pane_current_command")
|
|
729
|
+
fn agent_has_process_fact(agent: &Value) -> bool {
|
|
730
|
+
agent.get("pid").and_then(Value::as_i64).is_some()
|
|
731
|
+
|| agent.get("process_started").and_then(Value::as_bool) == Some(true)
|
|
732
|
+
|| agent
|
|
733
|
+
.get("provider_process_dead")
|
|
734
|
+
.and_then(Value::as_bool)
|
|
735
|
+
.is_some()
|
|
736
|
+
|| agent
|
|
737
|
+
.get("process_liveness")
|
|
683
738
|
.and_then(Value::as_str)
|
|
684
|
-
.
|
|
685
|
-
|
|
686
|
-
return false;
|
|
687
|
-
};
|
|
688
|
-
let Some(provider_wire) = agent.get("provider").and_then(Value::as_str) else {
|
|
689
|
-
return false;
|
|
690
|
-
};
|
|
691
|
-
let Some(provider) = crate::provider::wire::parse_provider(provider_wire) else {
|
|
692
|
-
return false;
|
|
693
|
-
};
|
|
694
|
-
crate::leader::command_matches_provider(provider, command)
|
|
695
|
-
}
|
|
739
|
+
.is_some()
|
|
740
|
+
}
|
|
696
741
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
.get(*key)
|
|
701
|
-
.and_then(Value::as_str)
|
|
702
|
-
.is_some_and(|value| !value.is_empty())
|
|
703
|
-
})
|
|
742
|
+
fn agent_process_dead(agent: &Value) -> bool {
|
|
743
|
+
if agent.get("provider_process_dead").and_then(Value::as_bool) == Some(true) {
|
|
744
|
+
return true;
|
|
704
745
|
}
|
|
746
|
+
["process_liveness", "worker_state"].iter().any(|key| {
|
|
747
|
+
agent
|
|
748
|
+
.get(*key)
|
|
749
|
+
.and_then(Value::as_str)
|
|
750
|
+
.is_some_and(is_dead_process_state)
|
|
751
|
+
})
|
|
752
|
+
}
|
|
705
753
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
754
|
+
fn is_dead_process_state(value: &str) -> bool {
|
|
755
|
+
matches!(
|
|
756
|
+
value,
|
|
757
|
+
"dead" | "missing" | "stopped" | "exited" | "terminated"
|
|
758
|
+
)
|
|
759
|
+
}
|
|
712
760
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
.get(*key)
|
|
720
|
-
.and_then(Value::as_str)
|
|
721
|
-
.is_some_and(is_dead_process_state)
|
|
722
|
-
})
|
|
761
|
+
fn interacted_marker(value: Option<&Value>) -> String {
|
|
762
|
+
let Some(raw) = value.and_then(Value::as_str) else {
|
|
763
|
+
return "never".to_string();
|
|
764
|
+
};
|
|
765
|
+
if raw.is_empty() {
|
|
766
|
+
return "never".to_string();
|
|
723
767
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
"dead" | "missing" | "stopped" | "exited" | "terminated"
|
|
729
|
-
)
|
|
768
|
+
if chrono::DateTime::parse_from_rfc3339(raw).is_ok() {
|
|
769
|
+
raw.to_string()
|
|
770
|
+
} else {
|
|
771
|
+
"never".to_string()
|
|
730
772
|
}
|
|
773
|
+
}
|
|
731
774
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
775
|
+
fn tmux_session_present(workspace: &Path, state: &Value, session_name: Option<&str>) -> bool {
|
|
776
|
+
// Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint
|
|
777
|
+
// the runtime actually uses (state.tmux_endpoint / tmux_socket), not
|
|
778
|
+
// the workspace-hash socket. When state has no persisted endpoint,
|
|
779
|
+
// fall back to workspace — preserves legacy behavior. wait_readiness
|
|
780
|
+
// formula unchanged per 不可改项; only the input signal is fixed.
|
|
781
|
+
let Some(name) = session_name else {
|
|
782
|
+
return false;
|
|
783
|
+
};
|
|
784
|
+
if name.is_empty() {
|
|
785
|
+
return false;
|
|
744
786
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// behavior because factory Layer 3 (legacy tmux endpoint) uses
|
|
770
|
-
// the same `tmux_backend_for_runtime_state_or_workspace` shape.
|
|
771
|
-
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
772
|
-
&run_ws,
|
|
773
|
-
Some(state),
|
|
774
|
-
crate::transport_factory::TransportPurpose::Status,
|
|
775
|
-
);
|
|
776
|
-
match resolved {
|
|
777
|
-
Ok(r) => r
|
|
778
|
-
.backend
|
|
779
|
-
.has_session(&crate::transport::SessionName::new(name))
|
|
780
|
-
.unwrap_or(false),
|
|
781
|
-
Err(_) => {
|
|
782
|
-
// Factory refused (e.g. explicit conpty without a
|
|
783
|
-
// resolvable team_key). Honest: return false rather
|
|
784
|
-
// than pretend a tmux session exists.
|
|
785
|
-
false
|
|
786
|
-
}
|
|
787
|
+
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
788
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
789
|
+
// 0.5.x Phase 1d Batch 3: route through the factory so a
|
|
790
|
+
// conpty team does NOT get its `has_session` probe served by a
|
|
791
|
+
// tmux backend (which would always return false and drive the
|
|
792
|
+
// reader into a false `tmux_session_missing` state — design
|
|
793
|
+
// §Batch 3 Verification anchor). Tmux teams see byte-equivalent
|
|
794
|
+
// behavior because factory Layer 3 (legacy tmux endpoint) uses
|
|
795
|
+
// the same `tmux_backend_for_runtime_state_or_workspace` shape.
|
|
796
|
+
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
797
|
+
&run_ws,
|
|
798
|
+
Some(state),
|
|
799
|
+
crate::transport_factory::TransportPurpose::Status,
|
|
800
|
+
);
|
|
801
|
+
match resolved {
|
|
802
|
+
Ok(r) => r
|
|
803
|
+
.backend
|
|
804
|
+
.has_session(&crate::transport::SessionName::new(name))
|
|
805
|
+
.unwrap_or(false),
|
|
806
|
+
Err(_) => {
|
|
807
|
+
// Factory refused (e.g. explicit conpty without a
|
|
808
|
+
// resolvable team_key). Honest: return false rather
|
|
809
|
+
// than pretend a tmux session exists.
|
|
810
|
+
false
|
|
787
811
|
}
|
|
788
812
|
}
|
|
813
|
+
}
|
|
789
814
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
815
|
+
fn message_counts(
|
|
816
|
+
conn: &rusqlite::Connection,
|
|
817
|
+
owner_team_id: Option<&str>,
|
|
818
|
+
) -> Result<Value, CliError> {
|
|
819
|
+
status_counts(conn, "messages", owner_team_id)
|
|
820
|
+
}
|
|
793
821
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
822
|
+
fn result_counts(
|
|
823
|
+
conn: &rusqlite::Connection,
|
|
824
|
+
owner_team_id: Option<&str>,
|
|
825
|
+
) -> Result<Value, CliError> {
|
|
826
|
+
let by_status = result_status_counts(conn, owner_team_id)?;
|
|
827
|
+
let total = count_rows(conn, "results", owner_team_id)?;
|
|
828
|
+
let invalid = count_where_status(conn, "results", owner_team_id, "invalid")?;
|
|
829
|
+
let collected = count_where_status(conn, "results", owner_team_id, "collected")?;
|
|
830
|
+
let uncollected = total.saturating_sub(collected).saturating_sub(invalid);
|
|
831
|
+
Ok(json!({
|
|
832
|
+
"total": total,
|
|
833
|
+
"uncollected": uncollected,
|
|
834
|
+
"collected": collected,
|
|
835
|
+
"invalid": invalid,
|
|
836
|
+
"by_status": by_status,
|
|
837
|
+
}))
|
|
838
|
+
}
|
|
808
839
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
840
|
+
fn status_counts(
|
|
841
|
+
conn: &rusqlite::Connection,
|
|
842
|
+
table: &str,
|
|
843
|
+
owner_team_id: Option<&str>,
|
|
844
|
+
) -> Result<Value, CliError> {
|
|
845
|
+
let sql = match owner_team_id {
|
|
846
|
+
Some(_) => format!(
|
|
847
|
+
"select status, count(*) from {table}
|
|
817
848
|
where owner_team_id = ?1
|
|
818
849
|
group by status order by status"
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
850
|
+
),
|
|
851
|
+
None => format!("select status, count(*) from {table} group by status order by status"),
|
|
852
|
+
};
|
|
853
|
+
let mut stmt = conn
|
|
854
|
+
.prepare(&sql)
|
|
855
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
856
|
+
let mut rows = match owner_team_id {
|
|
857
|
+
Some(team) => stmt
|
|
858
|
+
.query(params![team])
|
|
859
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
860
|
+
None => stmt
|
|
861
|
+
.query([])
|
|
862
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
863
|
+
};
|
|
864
|
+
let mut out = Map::new();
|
|
865
|
+
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
866
|
+
let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
867
|
+
let count: i64 = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
868
|
+
out.insert(status, json!(count));
|
|
834
869
|
}
|
|
870
|
+
Ok(Value::Object(out))
|
|
871
|
+
}
|
|
835
872
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
873
|
+
fn result_status_counts(
|
|
874
|
+
conn: &rusqlite::Connection,
|
|
875
|
+
owner_team_id: Option<&str>,
|
|
876
|
+
) -> Result<Value, CliError> {
|
|
877
|
+
let sql = match owner_team_id {
|
|
878
|
+
Some(_) => {
|
|
879
|
+
"select status, count(*) from results
|
|
840
880
|
where status not in ('collected', 'invalid') and owner_team_id = ?1
|
|
841
881
|
group by status
|
|
842
882
|
order by status"
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
883
|
+
}
|
|
884
|
+
None => {
|
|
885
|
+
"select status, count(*) from results
|
|
846
886
|
where status not in ('collected', 'invalid')
|
|
847
887
|
group by status
|
|
848
888
|
order by status"
|
|
849
|
-
}
|
|
850
|
-
};
|
|
851
|
-
let mut stmt = conn
|
|
852
|
-
.prepare(sql)
|
|
853
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
854
|
-
let mut rows = match owner_team_id {
|
|
855
|
-
Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
856
|
-
None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
857
|
-
};
|
|
858
|
-
let mut out = Map::new();
|
|
859
|
-
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
860
|
-
let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
861
|
-
let count: i64 = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
862
|
-
out.insert(status, json!(count));
|
|
863
889
|
}
|
|
864
|
-
|
|
890
|
+
};
|
|
891
|
+
let mut stmt = conn
|
|
892
|
+
.prepare(sql)
|
|
893
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
894
|
+
let mut rows = match owner_team_id {
|
|
895
|
+
Some(team) => stmt
|
|
896
|
+
.query(params![team])
|
|
897
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
898
|
+
None => stmt
|
|
899
|
+
.query([])
|
|
900
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
901
|
+
};
|
|
902
|
+
let mut out = Map::new();
|
|
903
|
+
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
904
|
+
let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
905
|
+
let count: i64 = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
906
|
+
out.insert(status, json!(count));
|
|
865
907
|
}
|
|
908
|
+
Ok(Value::Object(out))
|
|
909
|
+
}
|
|
866
910
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
911
|
+
fn queued_messages(
|
|
912
|
+
conn: &rusqlite::Connection,
|
|
913
|
+
owner_team_id: Option<&str>,
|
|
914
|
+
limit: usize,
|
|
915
|
+
) -> Result<Value, CliError> {
|
|
916
|
+
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
917
|
+
let sql = match owner_team_id {
|
|
918
|
+
Some(_) => {
|
|
919
|
+
"select message_id, recipient, status, created_at, delivery_attempts
|
|
876
920
|
from messages
|
|
877
921
|
where status like 'queued%' and owner_team_id = ?1
|
|
878
922
|
order by created_at desc
|
|
879
923
|
limit ?2"
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
924
|
+
}
|
|
925
|
+
None => {
|
|
926
|
+
"select message_id, recipient, status, created_at, delivery_attempts
|
|
883
927
|
from messages
|
|
884
928
|
where status like 'queued%'
|
|
885
929
|
order by created_at desc
|
|
886
930
|
limit ?1"
|
|
887
|
-
}
|
|
888
|
-
};
|
|
889
|
-
let mut stmt = conn
|
|
890
|
-
.prepare(sql)
|
|
891
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
892
|
-
let map_row = |row: &rusqlite::Row<'_>| {
|
|
893
|
-
Ok(json!({
|
|
894
|
-
"message_id": row.get::<_, String>(0)?,
|
|
895
|
-
"recipient": row.get::<_, Option<String>>(1)?,
|
|
896
|
-
"status": row.get::<_, String>(2)?,
|
|
897
|
-
"created_at": row.get::<_, Option<String>>(3)?,
|
|
898
|
-
"delivery_attempts": row.get::<_, i64>(4)?,
|
|
899
|
-
}))
|
|
900
|
-
};
|
|
901
|
-
let rows = match owner_team_id {
|
|
902
|
-
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
903
|
-
None => stmt.query_map(params![limit], map_row),
|
|
904
931
|
}
|
|
932
|
+
};
|
|
933
|
+
let mut stmt = conn
|
|
934
|
+
.prepare(sql)
|
|
905
935
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
.
|
|
909
|
-
|
|
936
|
+
let map_row = |row: &rusqlite::Row<'_>| {
|
|
937
|
+
Ok(json!({
|
|
938
|
+
"message_id": row.get::<_, String>(0)?,
|
|
939
|
+
"recipient": row.get::<_, Option<String>>(1)?,
|
|
940
|
+
"status": row.get::<_, String>(2)?,
|
|
941
|
+
"created_at": row.get::<_, Option<String>>(3)?,
|
|
942
|
+
"delivery_attempts": row.get::<_, i64>(4)?,
|
|
943
|
+
}))
|
|
944
|
+
};
|
|
945
|
+
let rows = match owner_team_id {
|
|
946
|
+
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
947
|
+
None => stmt.query_map(params![limit], map_row),
|
|
910
948
|
}
|
|
949
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
950
|
+
let values = rows
|
|
951
|
+
.collect::<Result<Vec<_>, _>>()
|
|
952
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
953
|
+
Ok(Value::Array(values))
|
|
954
|
+
}
|
|
911
955
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
956
|
+
/// 0.5.5 gate054 round-2: leader notifications that were refused with
|
|
957
|
+
/// `rebind_required` (status=failed, error=leader_not_attached) sit as
|
|
958
|
+
/// failed rows in the store; without a dedicated status field the
|
|
959
|
+
/// operator sees only `messages.failed=N` and cannot tell that the
|
|
960
|
+
/// notifications are waiting for a rebind. This field surfaces them
|
|
961
|
+
/// alongside `queued_messages` so `attach-leader` / `takeover` is
|
|
962
|
+
/// visibly the fix. Once the pane is rebound the requeue path flips
|
|
963
|
+
/// each row back to `status=accepted` and it drops out of this list.
|
|
964
|
+
fn pending_leader_notifications(
|
|
965
|
+
conn: &rusqlite::Connection,
|
|
966
|
+
owner_team_id: Option<&str>,
|
|
967
|
+
limit: usize,
|
|
968
|
+
) -> Result<Value, CliError> {
|
|
969
|
+
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
970
|
+
// E6 (0.5.9 offline-mailbox §6.6): also surface `queued_until_leader_attach`
|
|
971
|
+
// rows (third-party sends into the leader mailbox) so the target owner can
|
|
972
|
+
// see them alongside the rebind-required failures. Channel wire label
|
|
973
|
+
// distinguishes the two so the operator can tell the two shapes apart.
|
|
974
|
+
let sql = match owner_team_id {
|
|
975
|
+
Some(_) => {
|
|
976
|
+
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
933
977
|
from messages
|
|
934
978
|
where recipient = 'leader'
|
|
935
979
|
and owner_team_id = ?1
|
|
@@ -939,9 +983,9 @@ use rusqlite::params;
|
|
|
939
983
|
)
|
|
940
984
|
order by created_at desc
|
|
941
985
|
limit ?2"
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
986
|
+
}
|
|
987
|
+
None => {
|
|
988
|
+
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
945
989
|
from messages
|
|
946
990
|
where recipient = 'leader'
|
|
947
991
|
and (
|
|
@@ -950,281 +994,294 @@ use rusqlite::params;
|
|
|
950
994
|
)
|
|
951
995
|
order by created_at desc
|
|
952
996
|
limit ?1"
|
|
953
|
-
}
|
|
954
|
-
};
|
|
955
|
-
let mut stmt = conn
|
|
956
|
-
.prepare(sql)
|
|
957
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
958
|
-
let map_row = |row: &rusqlite::Row<'_>| {
|
|
959
|
-
let status: String = row.get(2)?;
|
|
960
|
-
let channel = if status == "queued_until_leader_attach" {
|
|
961
|
-
"leader_mailbox"
|
|
962
|
-
} else {
|
|
963
|
-
"rebind_required"
|
|
964
|
-
};
|
|
965
|
-
Ok(json!({
|
|
966
|
-
"message_id": row.get::<_, String>(0)?,
|
|
967
|
-
"sender": row.get::<_, Option<String>>(1)?,
|
|
968
|
-
"status": status,
|
|
969
|
-
"error": row.get::<_, Option<String>>(3)?,
|
|
970
|
-
"created_at": row.get::<_, Option<String>>(4)?,
|
|
971
|
-
"delivery_attempts": row.get::<_, i64>(5)?,
|
|
972
|
-
"channel": channel,
|
|
973
|
-
"action": "run team-agent attach-leader or team-agent takeover",
|
|
974
|
-
}))
|
|
975
|
-
};
|
|
976
|
-
let rows = match owner_team_id {
|
|
977
|
-
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
978
|
-
None => stmt.query_map(params![limit], map_row),
|
|
979
997
|
}
|
|
998
|
+
};
|
|
999
|
+
let mut stmt = conn
|
|
1000
|
+
.prepare(sql)
|
|
980
1001
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
"
|
|
995
|
-
"
|
|
996
|
-
"
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
/// Synthesized readiness boolean for the slim payload. Stricter than the
|
|
1005
|
-
/// raw `readiness.ready` because it also folds in coordinator + schema +
|
|
1006
|
-
/// tmux session presence so operators don't need to read separate booleans.
|
|
1007
|
-
fn compact_ready(full: &Value, not_ready: &Value) -> bool {
|
|
1008
|
-
not_ready.is_null()
|
|
1009
|
-
&& full
|
|
1010
|
-
.get("readiness")
|
|
1011
|
-
.and_then(|r| r.get("ready"))
|
|
1012
|
-
.and_then(Value::as_bool)
|
|
1013
|
-
.unwrap_or(false)
|
|
1014
|
-
&& full
|
|
1015
|
-
.get("coordinator")
|
|
1016
|
-
.and_then(|c| c.get("status"))
|
|
1017
|
-
.and_then(Value::as_str)
|
|
1018
|
-
.is_some_and(|s| s == "running" || s == "ok")
|
|
1019
|
-
&& full
|
|
1020
|
-
.get("coordinator")
|
|
1021
|
-
.and_then(|c| c.get("schema_ok"))
|
|
1022
|
-
.and_then(Value::as_bool)
|
|
1023
|
-
.unwrap_or(true)
|
|
1002
|
+
let map_row = |row: &rusqlite::Row<'_>| {
|
|
1003
|
+
let status: String = row.get(2)?;
|
|
1004
|
+
let channel = if status == "queued_until_leader_attach" {
|
|
1005
|
+
"leader_mailbox"
|
|
1006
|
+
} else {
|
|
1007
|
+
"rebind_required"
|
|
1008
|
+
};
|
|
1009
|
+
Ok(json!({
|
|
1010
|
+
"message_id": row.get::<_, String>(0)?,
|
|
1011
|
+
"sender": row.get::<_, Option<String>>(1)?,
|
|
1012
|
+
"status": status,
|
|
1013
|
+
"error": row.get::<_, Option<String>>(3)?,
|
|
1014
|
+
"created_at": row.get::<_, Option<String>>(4)?,
|
|
1015
|
+
"delivery_attempts": row.get::<_, i64>(5)?,
|
|
1016
|
+
"channel": channel,
|
|
1017
|
+
"action": "run team-agent attach-leader or team-agent takeover",
|
|
1018
|
+
}))
|
|
1019
|
+
};
|
|
1020
|
+
let rows = match owner_team_id {
|
|
1021
|
+
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
1022
|
+
None => stmt.query_map(params![limit], map_row),
|
|
1024
1023
|
}
|
|
1024
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1025
|
+
let values = rows
|
|
1026
|
+
.collect::<Result<Vec<_>, _>>()
|
|
1027
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1028
|
+
Ok(Value::Array(values))
|
|
1029
|
+
}
|
|
1025
1030
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
.unwrap_or_default();
|
|
1043
|
-
let mut obj = Map::new();
|
|
1044
|
-
obj.insert(
|
|
1045
|
-
"reasons".to_string(),
|
|
1046
|
-
Value::Array(reasons.into_iter().map(Value::String).collect()),
|
|
1047
|
-
);
|
|
1048
|
-
obj.insert("agents".to_string(), Value::Array(agents));
|
|
1049
|
-
Value::Object(obj)
|
|
1050
|
-
}
|
|
1031
|
+
/// 0.4.x: slim default compact payload — exactly 7 top-level fields.
|
|
1032
|
+
/// Diagnostic detail moves to `--detail`. Plan:
|
|
1033
|
+
/// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
|
|
1034
|
+
fn compact_status(full: Value) -> Value {
|
|
1035
|
+
let not_ready = compact_not_ready(&full);
|
|
1036
|
+
let ready = compact_ready(&full, ¬_ready);
|
|
1037
|
+
json!({
|
|
1038
|
+
"ok": true,
|
|
1039
|
+
"team": full.get("team").cloned().unwrap_or(Value::Null),
|
|
1040
|
+
"session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
|
|
1041
|
+
"leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
|
|
1042
|
+
"ready": ready,
|
|
1043
|
+
"not_ready": not_ready,
|
|
1044
|
+
"agents": compact_agents(full.get("agents")),
|
|
1045
|
+
})
|
|
1046
|
+
}
|
|
1051
1047
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1048
|
+
/// Synthesized readiness boolean for the slim payload. Stricter than the
|
|
1049
|
+
/// raw `readiness.ready` because it also folds in coordinator + schema +
|
|
1050
|
+
/// tmux session presence so operators don't need to read separate booleans.
|
|
1051
|
+
fn compact_ready(full: &Value, not_ready: &Value) -> bool {
|
|
1052
|
+
not_ready.is_null()
|
|
1053
|
+
&& full
|
|
1054
|
+
.get("readiness")
|
|
1055
|
+
.and_then(|r| r.get("ready"))
|
|
1056
|
+
.and_then(Value::as_bool)
|
|
1057
|
+
.unwrap_or(false)
|
|
1058
|
+
&& full
|
|
1059
|
+
.get("coordinator")
|
|
1056
1060
|
.and_then(|c| c.get("status"))
|
|
1057
1061
|
.and_then(Value::as_str)
|
|
1058
|
-
.
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
}
|
|
1062
|
-
if coord
|
|
1062
|
+
.is_some_and(|s| s == "running" || s == "ok")
|
|
1063
|
+
&& full
|
|
1064
|
+
.get("coordinator")
|
|
1063
1065
|
.and_then(|c| c.get("schema_ok"))
|
|
1064
1066
|
.and_then(Value::as_bool)
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
reasons.push("coordinator_schema_not_ok".to_string());
|
|
1068
|
-
}
|
|
1069
|
-
if full
|
|
1070
|
-
.get("tmux_session_present")
|
|
1071
|
-
.and_then(Value::as_bool)
|
|
1072
|
-
== Some(false)
|
|
1073
|
-
{
|
|
1074
|
-
reasons.push("tmux_session_missing".to_string());
|
|
1075
|
-
}
|
|
1076
|
-
let readiness = full.get("readiness");
|
|
1077
|
-
if readiness
|
|
1078
|
-
.and_then(|r| r.get("all_spawned"))
|
|
1079
|
-
.and_then(Value::as_bool)
|
|
1080
|
-
== Some(false)
|
|
1081
|
-
{
|
|
1082
|
-
reasons.push("workers_not_spawned".to_string());
|
|
1083
|
-
}
|
|
1084
|
-
if readiness
|
|
1085
|
-
.and_then(|r| r.get("all_attached_receiver"))
|
|
1086
|
-
.and_then(Value::as_bool)
|
|
1087
|
-
== Some(false)
|
|
1088
|
-
{
|
|
1089
|
-
reasons.push("leader_receiver_unbound".to_string());
|
|
1090
|
-
}
|
|
1091
|
-
if readiness
|
|
1092
|
-
.and_then(|r| r.get("session_capture_complete"))
|
|
1093
|
-
.and_then(Value::as_bool)
|
|
1094
|
-
== Some(false)
|
|
1095
|
-
{
|
|
1096
|
-
reasons.push("session_capture_incomplete".to_string());
|
|
1097
|
-
}
|
|
1098
|
-
if readiness
|
|
1099
|
-
.and_then(|r| r.get("awaiting_trust_prompt"))
|
|
1100
|
-
.and_then(Value::as_bool)
|
|
1101
|
-
== Some(true)
|
|
1102
|
-
{
|
|
1103
|
-
reasons.push("awaiting_trust_prompt".to_string());
|
|
1104
|
-
}
|
|
1105
|
-
reasons
|
|
1106
|
-
}
|
|
1067
|
+
.unwrap_or(true)
|
|
1068
|
+
}
|
|
1107
1069
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
out.insert(agent_id.clone(), compact_agent_state(agent_id, agent));
|
|
1115
|
-
}
|
|
1116
|
-
Value::Object(out)
|
|
1070
|
+
/// Returns `Value::Null` when fully ready, otherwise an object:
|
|
1071
|
+
/// `{"reasons": [...], "agents": [...]}` listing every gating issue.
|
|
1072
|
+
fn compact_not_ready(full: &Value) -> Value {
|
|
1073
|
+
let reasons = not_ready_reasons(full);
|
|
1074
|
+
if reasons.is_empty() {
|
|
1075
|
+
return Value::Null;
|
|
1117
1076
|
}
|
|
1077
|
+
let agents = full
|
|
1078
|
+
.get("incomplete_session_capture_agents")
|
|
1079
|
+
.and_then(Value::as_array)
|
|
1080
|
+
.cloned()
|
|
1081
|
+
.or_else(|| {
|
|
1082
|
+
full.get("pending_session_agent_ids")
|
|
1083
|
+
.and_then(Value::as_array)
|
|
1084
|
+
.cloned()
|
|
1085
|
+
})
|
|
1086
|
+
.unwrap_or_default();
|
|
1087
|
+
let mut obj = Map::new();
|
|
1088
|
+
obj.insert(
|
|
1089
|
+
"reasons".to_string(),
|
|
1090
|
+
Value::Array(reasons.into_iter().map(Value::String).collect()),
|
|
1091
|
+
);
|
|
1092
|
+
obj.insert("agents".to_string(), Value::Array(agents));
|
|
1093
|
+
Value::Object(obj)
|
|
1094
|
+
}
|
|
1118
1095
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1096
|
+
fn not_ready_reasons(full: &Value) -> Vec<String> {
|
|
1097
|
+
let mut reasons = Vec::new();
|
|
1098
|
+
let coord = full.get("coordinator");
|
|
1099
|
+
let coord_status = coord
|
|
1100
|
+
.and_then(|c| c.get("status"))
|
|
1101
|
+
.and_then(Value::as_str)
|
|
1102
|
+
.unwrap_or("");
|
|
1103
|
+
if coord_status != "running" && coord_status != "ok" {
|
|
1104
|
+
reasons.push("coordinator_not_running".to_string());
|
|
1105
|
+
}
|
|
1106
|
+
if coord
|
|
1107
|
+
.and_then(|c| c.get("schema_ok"))
|
|
1108
|
+
.and_then(Value::as_bool)
|
|
1109
|
+
== Some(false)
|
|
1110
|
+
{
|
|
1111
|
+
reasons.push("coordinator_schema_not_ok".to_string());
|
|
1112
|
+
}
|
|
1113
|
+
if full.get("tmux_session_present").and_then(Value::as_bool) == Some(false) {
|
|
1114
|
+
reasons.push("tmux_session_missing".to_string());
|
|
1115
|
+
}
|
|
1116
|
+
let readiness = full.get("readiness");
|
|
1117
|
+
if readiness
|
|
1118
|
+
.and_then(|r| r.get("all_spawned"))
|
|
1119
|
+
.and_then(Value::as_bool)
|
|
1120
|
+
== Some(false)
|
|
1121
|
+
{
|
|
1122
|
+
reasons.push("workers_not_spawned".to_string());
|
|
1123
|
+
}
|
|
1124
|
+
if readiness
|
|
1125
|
+
.and_then(|r| r.get("all_attached_receiver"))
|
|
1126
|
+
.and_then(Value::as_bool)
|
|
1127
|
+
== Some(false)
|
|
1128
|
+
{
|
|
1129
|
+
reasons.push("leader_receiver_unbound".to_string());
|
|
1146
1130
|
}
|
|
1131
|
+
if readiness
|
|
1132
|
+
.and_then(|r| r.get("session_capture_complete"))
|
|
1133
|
+
.and_then(Value::as_bool)
|
|
1134
|
+
== Some(false)
|
|
1135
|
+
{
|
|
1136
|
+
reasons.push("session_capture_incomplete".to_string());
|
|
1137
|
+
}
|
|
1138
|
+
if readiness
|
|
1139
|
+
.and_then(|r| r.get("awaiting_trust_prompt"))
|
|
1140
|
+
.and_then(Value::as_bool)
|
|
1141
|
+
== Some(true)
|
|
1142
|
+
{
|
|
1143
|
+
reasons.push("awaiting_trust_prompt".to_string());
|
|
1144
|
+
}
|
|
1145
|
+
reasons
|
|
1146
|
+
}
|
|
1147
1147
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
.collect(),
|
|
1156
|
-
)
|
|
1148
|
+
fn compact_agents(value: Option<&Value>) -> Value {
|
|
1149
|
+
let Some(Value::Object(input)) = value else {
|
|
1150
|
+
return json!({});
|
|
1151
|
+
};
|
|
1152
|
+
let mut out = Map::new();
|
|
1153
|
+
for (agent_id, agent) in input {
|
|
1154
|
+
out.insert(agent_id.clone(), compact_agent_state(agent_id, agent));
|
|
1157
1155
|
}
|
|
1156
|
+
Value::Object(out)
|
|
1157
|
+
}
|
|
1158
1158
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1159
|
+
/// 0.4.x: agent rows in the slim payload have exactly 4 fields. agent_id
|
|
1160
|
+
/// is no longer copied in — the map key already carries it. Diagnostic
|
|
1161
|
+
/// fields (model, tmux_window_present, session_id, captured_via,
|
|
1162
|
+
/// attribution_confidence, display, interacted) move to `--detail`.
|
|
1163
|
+
/// `activity` + `last_output_at` are preserved (RM-039-STAT-001).
|
|
1164
|
+
fn compact_agent_state(_agent_id: &str, agent: &Value) -> Value {
|
|
1165
|
+
let Some(input) = agent.as_object() else {
|
|
1166
|
+
return json!({});
|
|
1167
|
+
};
|
|
1168
|
+
let mut out = Map::new();
|
|
1169
|
+
// 0.4.x Phase 1: add `worker_state` (canonical 5-state product
|
|
1170
|
+
// surface). `activity` is preserved alongside as the deprecated
|
|
1171
|
+
// legacy classifier output (CR R3 same-source contract).
|
|
1172
|
+
for key in [
|
|
1173
|
+
"status",
|
|
1174
|
+
"provider",
|
|
1175
|
+
"worker_state",
|
|
1176
|
+
"activity",
|
|
1177
|
+
"last_output_at",
|
|
1178
|
+
"stale",
|
|
1179
|
+
"stale_reason",
|
|
1180
|
+
] {
|
|
1181
|
+
if let Some(value) = input.get(key) {
|
|
1182
|
+
out.insert(key.to_string(), value.clone());
|
|
1168
1183
|
}
|
|
1169
|
-
Value::Object(out)
|
|
1170
1184
|
}
|
|
1185
|
+
Value::Object(out)
|
|
1186
|
+
}
|
|
1171
1187
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1188
|
+
fn compact_tasks(value: Option<&Value>) -> Value {
|
|
1189
|
+
let Some(Value::Array(tasks)) = value else {
|
|
1190
|
+
return json!([]);
|
|
1191
|
+
};
|
|
1192
|
+
Value::Array(
|
|
1193
|
+
tasks
|
|
1194
|
+
.iter()
|
|
1195
|
+
.map(|task| {
|
|
1196
|
+
compact_object(
|
|
1197
|
+
Some(task),
|
|
1198
|
+
&[
|
|
1199
|
+
"id",
|
|
1200
|
+
"title",
|
|
1201
|
+
"status",
|
|
1202
|
+
"assignee",
|
|
1203
|
+
"type",
|
|
1204
|
+
"accepted_result_id",
|
|
1205
|
+
],
|
|
1206
|
+
)
|
|
1207
|
+
})
|
|
1208
|
+
.collect(),
|
|
1209
|
+
)
|
|
1210
|
+
}
|
|
1178
1211
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1212
|
+
fn compact_object(value: Option<&Value>, keys: &[&str]) -> Value {
|
|
1213
|
+
let Some(Value::Object(input)) = value else {
|
|
1214
|
+
return json!({});
|
|
1215
|
+
};
|
|
1216
|
+
let mut out = Map::new();
|
|
1217
|
+
for key in keys {
|
|
1218
|
+
if let Some(value) = input.get(*key) {
|
|
1219
|
+
out.insert((*key).to_string(), value.clone());
|
|
1220
|
+
}
|
|
1185
1221
|
}
|
|
1222
|
+
Value::Object(out)
|
|
1223
|
+
}
|
|
1186
1224
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
)
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1225
|
+
fn take_array(value: Option<&Value>, limit: usize) -> Value {
|
|
1226
|
+
let Some(Value::Array(items)) = value else {
|
|
1227
|
+
return json!([]);
|
|
1228
|
+
};
|
|
1229
|
+
Value::Array(items.iter().take(limit).cloned().collect())
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
fn take_array_tail(value: Option<&Value>, limit: usize) -> Value {
|
|
1233
|
+
let Some(Value::Array(items)) = value else {
|
|
1234
|
+
return json!([]);
|
|
1235
|
+
};
|
|
1236
|
+
let start = items.len().saturating_sub(limit);
|
|
1237
|
+
Value::Array(items.iter().skip(start).cloned().collect())
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
fn count_rows(
|
|
1241
|
+
conn: &rusqlite::Connection,
|
|
1242
|
+
table: &str,
|
|
1243
|
+
owner_team_id: Option<&str>,
|
|
1244
|
+
) -> Result<i64, CliError> {
|
|
1245
|
+
match owner_team_id {
|
|
1246
|
+
Some(team) => {
|
|
1247
|
+
let sql = format!("select count(*) from {table} where owner_team_id = ?1");
|
|
1248
|
+
conn.query_row(&sql, [team], |row| row.get::<_, i64>(0))
|
|
1249
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
1250
|
+
}
|
|
1251
|
+
None => {
|
|
1252
|
+
let sql = format!("select count(*) from {table}");
|
|
1253
|
+
conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
|
|
1254
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
1203
1255
|
}
|
|
1204
1256
|
}
|
|
1257
|
+
}
|
|
1205
1258
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1259
|
+
fn count_where_status(
|
|
1260
|
+
conn: &rusqlite::Connection,
|
|
1261
|
+
table: &str,
|
|
1262
|
+
owner_team_id: Option<&str>,
|
|
1263
|
+
status: &str,
|
|
1264
|
+
) -> Result<i64, CliError> {
|
|
1265
|
+
match owner_team_id {
|
|
1266
|
+
Some(team) => {
|
|
1267
|
+
let sql =
|
|
1268
|
+
format!("select count(*) from {table} where status = ?1 and owner_team_id = ?2");
|
|
1269
|
+
conn.query_row(&sql, params![status, team], |row| row.get::<_, i64>(0))
|
|
1270
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
1271
|
+
}
|
|
1272
|
+
None => {
|
|
1273
|
+
let sql = format!("select count(*) from {table} where status = ?1");
|
|
1274
|
+
conn.query_row(&sql, [status], |row| row.get::<_, i64>(0))
|
|
1275
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
1223
1276
|
}
|
|
1224
1277
|
}
|
|
1278
|
+
}
|
|
1225
1279
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1280
|
+
fn agent_health(
|
|
1281
|
+
conn: &rusqlite::Connection,
|
|
1282
|
+
owner_team_id: Option<&str>,
|
|
1283
|
+
) -> Result<Value, CliError> {
|
|
1284
|
+
let sql = match owner_team_id {
|
|
1228
1285
|
Some(_) => {
|
|
1229
1286
|
"select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
|
|
1230
1287
|
from agent_health where owner_team_id = ?1 order by agent_id"
|
|
@@ -1234,215 +1291,227 @@ use rusqlite::params;
|
|
|
1234
1291
|
from agent_health order by agent_id"
|
|
1235
1292
|
}
|
|
1236
1293
|
};
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
)
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1294
|
+
let mut stmt = conn
|
|
1295
|
+
.prepare(sql)
|
|
1296
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1297
|
+
let mut rows = match owner_team_id {
|
|
1298
|
+
Some(team) => stmt
|
|
1299
|
+
.query(params![team])
|
|
1300
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1301
|
+
None => stmt
|
|
1302
|
+
.query([])
|
|
1303
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1304
|
+
};
|
|
1305
|
+
let mut out = Map::new();
|
|
1306
|
+
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
1307
|
+
let agent_id: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1308
|
+
let status: String = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1309
|
+
let mut item = Map::new();
|
|
1310
|
+
item.insert("status".to_string(), json!(status));
|
|
1311
|
+
item.insert(
|
|
1312
|
+
"health_status".to_string(),
|
|
1313
|
+
json!(crate::provider::agent_health_status(
|
|
1314
|
+
item.get("status").and_then(Value::as_str).unwrap_or("")
|
|
1315
|
+
)),
|
|
1316
|
+
);
|
|
1317
|
+
insert_optional_string(
|
|
1318
|
+
&mut item,
|
|
1319
|
+
"last_output_at",
|
|
1320
|
+
row.get(2).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1321
|
+
);
|
|
1322
|
+
insert_optional_i64(
|
|
1323
|
+
&mut item,
|
|
1324
|
+
"context_usage_pct",
|
|
1325
|
+
row.get(3).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1326
|
+
);
|
|
1327
|
+
let current_task_id: Option<String> =
|
|
1328
|
+
row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1329
|
+
let has_current_task = current_task_id.is_some();
|
|
1330
|
+
insert_optional_string(&mut item, "current_task_id", current_task_id);
|
|
1331
|
+
let updated_at: String = row.get(5).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1332
|
+
// Phase-DX E2 (plan §4 / CR supplement A): expose the last agent_health
|
|
1333
|
+
// observation timestamp as `health_updated_at` alongside the legacy
|
|
1334
|
+
// `updated_at` alias. Two names for one column keep old scrapers working
|
|
1335
|
+
// while surfacing the semantic (heartbeat, not row bookkeeping).
|
|
1336
|
+
item.insert("updated_at".to_string(), json!(updated_at.clone()));
|
|
1337
|
+
item.insert("health_updated_at".to_string(), json!(updated_at));
|
|
1338
|
+
// Phase-DX E2 (CR P0 red line #6, supplements A/B): current_task is a
|
|
1339
|
+
// best-effort *display* field until A1 makes task FSM authoritative.
|
|
1340
|
+
// The structured source/confidence markers stop downstream code from
|
|
1341
|
+
// treating agent_health.current_task_id as authority. `current_task_source`
|
|
1342
|
+
// records where the display value came from (only "health" today —
|
|
1343
|
+
// Phase-DX never merges state tasks into this projection); the
|
|
1344
|
+
// `current_task_confidence` enum stays "best_effort" for the whole
|
|
1345
|
+
// Phase-DX slice (A1 will later flip it to "authoritative" when the
|
|
1346
|
+
// task FSM lands). Field is written unconditionally so consumers can
|
|
1347
|
+
// switch on it even when `current_task_id` is null.
|
|
1348
|
+
item.insert(
|
|
1349
|
+
"current_task_source".to_string(),
|
|
1350
|
+
json!(if has_current_task { "health" } else { "none" }),
|
|
1351
|
+
);
|
|
1352
|
+
item.insert("current_task_confidence".to_string(), json!("best_effort"));
|
|
1353
|
+
insert_optional_string(
|
|
1354
|
+
&mut item,
|
|
1355
|
+
"owner_team_id",
|
|
1356
|
+
row.get(6).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1357
|
+
);
|
|
1358
|
+
out.insert(agent_id, Value::Object(item));
|
|
1292
1359
|
}
|
|
1360
|
+
Ok(Value::Object(out))
|
|
1361
|
+
}
|
|
1293
1362
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
}
|
|
1363
|
+
fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<String>) {
|
|
1364
|
+
if let Some(value) = value {
|
|
1365
|
+
map.insert(key.to_string(), Value::String(value));
|
|
1298
1366
|
}
|
|
1367
|
+
}
|
|
1299
1368
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
}
|
|
1369
|
+
fn insert_optional_i64(map: &mut Map<String, Value>, key: &str, value: Option<i64>) {
|
|
1370
|
+
if let Some(value) = value {
|
|
1371
|
+
map.insert(key.to_string(), json!(value));
|
|
1304
1372
|
}
|
|
1373
|
+
}
|
|
1305
1374
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1375
|
+
/// B-5 / 036b N38 — status 出口的 runtime 块:把 coordinator_health 与
|
|
1376
|
+
/// undelivered backlog 合体暴露。down-hint 只在【coordinator 不在跑 ∧ 有 backlog】
|
|
1377
|
+
/// 两条件同时满足才挂(anti-nag);健康状态下不挂提示。auto-recovery 不做。
|
|
1378
|
+
fn build_runtime_status_block(
|
|
1379
|
+
coordinator_running: bool,
|
|
1380
|
+
undelivered: i64,
|
|
1381
|
+
tmux_session_missing: bool,
|
|
1382
|
+
freshness: &RuntimeFreshness,
|
|
1383
|
+
) -> Value {
|
|
1384
|
+
let mut runtime = serde_json::Map::new();
|
|
1385
|
+
runtime.insert(
|
|
1386
|
+
"coordinator".to_string(),
|
|
1387
|
+
json!({"ok": coordinator_running}),
|
|
1388
|
+
);
|
|
1389
|
+
runtime.insert("undelivered".to_string(), json!(undelivered));
|
|
1390
|
+
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): expose
|
|
1391
|
+
// typed issues on the runtime block so `status --json --detail`
|
|
1392
|
+
// can be scanned for `runtime_bindings_stale_after_boot` the
|
|
1393
|
+
// same way `diagnose` surfaces it. Hint precedence: session-
|
|
1394
|
+
// missing > host-boot-stale > coordinator-not-running-with-
|
|
1395
|
+
// backlog — most-actionable-first.
|
|
1396
|
+
let mut issues: Vec<Value> = Vec::new();
|
|
1397
|
+
if freshness.host_boot_stale {
|
|
1398
|
+
issues.push(json!({
|
|
1399
|
+
"id": "runtime_bindings_stale_after_boot",
|
|
1400
|
+
"recorded_host_boot_id": freshness.host_boot_recorded,
|
|
1401
|
+
"current_host_boot_id": freshness.host_boot_current,
|
|
1402
|
+
}));
|
|
1403
|
+
}
|
|
1404
|
+
if !issues.is_empty() {
|
|
1405
|
+
runtime.insert("issues".to_string(), Value::Array(issues));
|
|
1406
|
+
}
|
|
1407
|
+
if tmux_session_missing {
|
|
1316
1408
|
runtime.insert(
|
|
1317
|
-
"
|
|
1318
|
-
json!(
|
|
1409
|
+
"hint".to_string(),
|
|
1410
|
+
json!("tmux session missing — run team-agent restart"),
|
|
1411
|
+
);
|
|
1412
|
+
} else if freshness.host_boot_stale {
|
|
1413
|
+
runtime.insert(
|
|
1414
|
+
"hint".to_string(),
|
|
1415
|
+
json!("runtime_bindings_stale_after_boot — run team-agent restart"),
|
|
1416
|
+
);
|
|
1417
|
+
} else if !coordinator_running && undelivered > 0 {
|
|
1418
|
+
runtime.insert(
|
|
1419
|
+
"hint".to_string(),
|
|
1420
|
+
json!(format!(
|
|
1421
|
+
"coordinator not running with {undelivered} undelivered — run team-agent restart"
|
|
1422
|
+
)),
|
|
1319
1423
|
);
|
|
1320
|
-
runtime.insert("undelivered".to_string(), json!(undelivered));
|
|
1321
|
-
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): expose
|
|
1322
|
-
// typed issues on the runtime block so `status --json --detail`
|
|
1323
|
-
// can be scanned for `runtime_bindings_stale_after_boot` the
|
|
1324
|
-
// same way `diagnose` surfaces it. Hint precedence: session-
|
|
1325
|
-
// missing > host-boot-stale > coordinator-not-running-with-
|
|
1326
|
-
// backlog — most-actionable-first.
|
|
1327
|
-
let mut issues: Vec<Value> = Vec::new();
|
|
1328
|
-
if freshness.host_boot_stale {
|
|
1329
|
-
issues.push(json!({
|
|
1330
|
-
"id": "runtime_bindings_stale_after_boot",
|
|
1331
|
-
"recorded_host_boot_id": freshness.host_boot_recorded,
|
|
1332
|
-
"current_host_boot_id": freshness.host_boot_current,
|
|
1333
|
-
}));
|
|
1334
|
-
}
|
|
1335
|
-
if !issues.is_empty() {
|
|
1336
|
-
runtime.insert("issues".to_string(), Value::Array(issues));
|
|
1337
|
-
}
|
|
1338
|
-
if tmux_session_missing {
|
|
1339
|
-
runtime.insert(
|
|
1340
|
-
"hint".to_string(),
|
|
1341
|
-
json!("tmux session missing — run team-agent restart"),
|
|
1342
|
-
);
|
|
1343
|
-
} else if freshness.host_boot_stale {
|
|
1344
|
-
runtime.insert(
|
|
1345
|
-
"hint".to_string(),
|
|
1346
|
-
json!("runtime_bindings_stale_after_boot — run team-agent restart"),
|
|
1347
|
-
);
|
|
1348
|
-
} else if !coordinator_running && undelivered > 0 {
|
|
1349
|
-
runtime.insert(
|
|
1350
|
-
"hint".to_string(),
|
|
1351
|
-
json!(format!(
|
|
1352
|
-
"coordinator not running with {undelivered} undelivered — run team-agent restart"
|
|
1353
|
-
)),
|
|
1354
|
-
);
|
|
1355
|
-
}
|
|
1356
|
-
Value::Object(runtime)
|
|
1357
1424
|
}
|
|
1425
|
+
Value::Object(runtime)
|
|
1426
|
+
}
|
|
1358
1427
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1428
|
+
/// Whether the coordinator HealthReport reflects a running tick loop. Used by the
|
|
1429
|
+
/// runtime block + the hint gate.
|
|
1430
|
+
fn coordinator_status_running(health: &crate::coordinator::HealthReport) -> bool {
|
|
1431
|
+
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): runtime
|
|
1432
|
+
// service predicate is `service_available`, not any-`Running` pid.
|
|
1433
|
+
// A stale-pid daemon is CoordinatorHealthStatus::Stale (or Running
|
|
1434
|
+
// with metadata_ok=false); a service-compatible newer daemon is
|
|
1435
|
+
// Running + service_available=true — send/diagnose already use
|
|
1436
|
+
// this same truth source, and status must agree.
|
|
1437
|
+
health.service_available
|
|
1438
|
+
}
|
|
1370
1439
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1440
|
+
/// Count of messages currently sitting in delivery-able backlog
|
|
1441
|
+
/// (accepted/pending/queued forms — not delivered / not failed / not refused).
|
|
1442
|
+
/// owner_team_id scope honored when present.
|
|
1443
|
+
fn count_undelivered_backlog(
|
|
1444
|
+
conn: &rusqlite::Connection,
|
|
1445
|
+
owner_team_id: Option<&str>,
|
|
1446
|
+
) -> Result<i64, CliError> {
|
|
1447
|
+
// Backlog statuses chosen to mirror what `deliver_pending` would pick up.
|
|
1448
|
+
let sql = match owner_team_id {
|
|
1380
1449
|
Some(_) => "select count(*) from messages
|
|
1381
1450
|
where owner_team_id = ?1 and status in ('accepted','pending','queued','queued_until_trust')",
|
|
1382
1451
|
None => "select count(*) from messages
|
|
1383
1452
|
where status in ('accepted','pending','queued','queued_until_trust')",
|
|
1384
1453
|
};
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1454
|
+
let count: i64 = match owner_team_id {
|
|
1455
|
+
Some(team) => conn
|
|
1456
|
+
.query_row(sql, params![team], |row| row.get(0))
|
|
1457
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1458
|
+
None => conn
|
|
1459
|
+
.query_row(sql, [], |row| row.get(0))
|
|
1460
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
1461
|
+
};
|
|
1462
|
+
Ok(count)
|
|
1463
|
+
}
|
|
1395
1464
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
}
|
|
1465
|
+
fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
|
|
1466
|
+
let expose_binary_drift = health.service_available && !health.metadata_ok;
|
|
1467
|
+
let binary_identity_relation = health.binary_identity_relation.as_str();
|
|
1468
|
+
let mut value = json!({
|
|
1469
|
+
"ok": health.ok,
|
|
1470
|
+
"status": coordinator_status_wire(health.status),
|
|
1471
|
+
"pid": health.pid.map(|p| p.get()),
|
|
1472
|
+
"metadata": health.metadata.map(|m| json!({
|
|
1473
|
+
"pid": m.pid.get(),
|
|
1474
|
+
"protocol_version": m.protocol_version,
|
|
1475
|
+
"message_store_schema_version": m.message_store_schema_version,
|
|
1476
|
+
"binary_path": m.binary_path,
|
|
1477
|
+
"binary_version": m.binary_version,
|
|
1478
|
+
"source": m.source,
|
|
1479
|
+
"updated_at": m.updated_at,
|
|
1480
|
+
})),
|
|
1481
|
+
"metadata_ok": health.metadata_ok,
|
|
1482
|
+
"metadata_mismatch_reason": health.metadata_mismatch_reason,
|
|
1483
|
+
"binary_path": health.current_binary_identity.binary_path,
|
|
1484
|
+
"binary_version": health.current_binary_identity.binary_version,
|
|
1485
|
+
"schema_ok": health.schema.ok,
|
|
1486
|
+
"schema_error": health.schema.error.map(|e| format!("{e:?}")),
|
|
1487
|
+
"schema": {
|
|
1488
|
+
"message_store_schema_version": health.schema.schema_version,
|
|
1489
|
+
},
|
|
1490
|
+
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3):
|
|
1491
|
+
// `service_available` is now always exposed alongside `ok`
|
|
1492
|
+
// and `status` so status/diagnose consumers can share one
|
|
1493
|
+
// service-availability truth without keying on the legacy
|
|
1494
|
+
// `expose_binary_drift` conditional (that path only fired
|
|
1495
|
+
// for the newer-daemon-preserved shape). `binary_identity_
|
|
1496
|
+
// relation` moves to always-on for the same reason — RED3
|
|
1497
|
+
// scans the summary for `daemon_newer_than_caller`.
|
|
1498
|
+
"service_available": health.service_available,
|
|
1499
|
+
"binary_identity_relation": binary_identity_relation,
|
|
1500
|
+
});
|
|
1501
|
+
if expose_binary_drift {
|
|
1502
|
+
if let Some(obj) = value.as_object_mut() {
|
|
1503
|
+
obj.insert("wire_metadata_ok".to_string(), Value::Bool(true));
|
|
1504
|
+
obj.insert("binary_identity_ok".to_string(), Value::Bool(false));
|
|
1437
1505
|
}
|
|
1438
|
-
value
|
|
1439
1506
|
}
|
|
1507
|
+
value
|
|
1508
|
+
}
|
|
1440
1509
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1510
|
+
fn coordinator_status_wire(status: crate::coordinator::CoordinatorHealthStatus) -> &'static str {
|
|
1511
|
+
match status {
|
|
1512
|
+
crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
|
|
1513
|
+
crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
|
|
1514
|
+
crate::coordinator::CoordinatorHealthStatus::Running => "running",
|
|
1515
|
+
crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
|
|
1448
1516
|
}
|
|
1517
|
+
}
|