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