@team-agent/installer 0.5.49 → 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.
Files changed (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -0,0 +1,145 @@
1
+ use crate::cli::CliError;
2
+ use serde_json::{json, Value};
3
+
4
+ /// `format_status` agent 分支(`queries.py:135-162`)。
5
+ pub(super) fn format_agent_status(
6
+ status: &Value,
7
+ agent_id: &str,
8
+ inbox_rows: &[Value],
9
+ ) -> Result<String, CliError> {
10
+ ensure_agent_known(status, agent_id)?;
11
+ let agents = status.get("agents").and_then(Value::as_object);
12
+ let health = status.get("agent_health").and_then(Value::as_object);
13
+ let empty = json!({});
14
+ let agent = agents.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
15
+ let row = health.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
16
+ let status_text = row
17
+ .get("status")
18
+ .and_then(Value::as_str)
19
+ .map(str::to_string)
20
+ .unwrap_or_else(|| {
21
+ agent_health_status_text(agent.get("status").and_then(Value::as_str).unwrap_or(""))
22
+ });
23
+ let tasks = status
24
+ .get("tasks")
25
+ .and_then(Value::as_array)
26
+ .cloned()
27
+ .unwrap_or_default();
28
+ let task_id = current_task_for_agent(&tasks, agent_id).unwrap_or_else(|| "-".to_string());
29
+ let mut lines = vec![
30
+ format!("{agent_id} {status_text}"),
31
+ format!(" provider: {}", py_get(agent, "provider")),
32
+ format!(" model: {}", py_get(agent, "model")),
33
+ format!(" profile: {}", py_get(agent, "profile")),
34
+ format!(" session_id: {}", py_get_or_dash(agent, "session_id")),
35
+ format!(" captured_via: {}", py_get_or_dash(agent, "captured_via")),
36
+ format!(
37
+ " attribution_confidence: {}",
38
+ py_get_or_dash(agent, "attribution_confidence")
39
+ ),
40
+ format!(" task: {task_id}"),
41
+ format!(" handoff: {}", py_get(agent, "handoff_path")),
42
+ " recent messages:".to_string(),
43
+ ];
44
+ if inbox_rows.is_empty() {
45
+ lines.push(" none".to_string());
46
+ } else {
47
+ for item in inbox_rows {
48
+ let content = item.get("content").and_then(Value::as_str).unwrap_or("");
49
+ let content: String = content.chars().take(120).collect();
50
+ lines.push(format!(
51
+ " {} {} -> {} {}: {content}",
52
+ py_get_or_dash(item, "created_at"),
53
+ py_get_or_dash(item, "sender"),
54
+ py_get_or_dash(item, "recipient"),
55
+ py_get_or_dash(item, "status"),
56
+ ));
57
+ }
58
+ }
59
+ Ok(lines.join("\n"))
60
+ }
61
+
62
+ pub(super) fn ensure_agent_known(status: &Value, agent_id: &str) -> Result<(), CliError> {
63
+ let agents = status.get("agents").and_then(Value::as_object);
64
+ let health = status.get("agent_health").and_then(Value::as_object);
65
+ let known = agents.is_some_and(|map| map.contains_key(agent_id))
66
+ || health.is_some_and(|map| map.contains_key(agent_id));
67
+ if !known {
68
+ return Err(CliError::Runtime(format!("unknown agent id: {agent_id}")));
69
+ }
70
+ Ok(())
71
+ }
72
+
73
+ /// `current_task_for_agent`(`approvals/status.py:127-132`)。
74
+ pub(super) fn current_task_for_agent(tasks: &[Value], agent_id: &str) -> Option<String> {
75
+ const ACTIVE: [&str; 5] = ["pending", "ready", "running", "blocked", "needs_retry"];
76
+ for task in tasks.iter().rev() {
77
+ let assignee = task.get("assignee").and_then(Value::as_str);
78
+ let status = task
79
+ .get("status")
80
+ .and_then(Value::as_str)
81
+ .unwrap_or("pending");
82
+ if assignee == Some(agent_id) && ACTIVE.contains(&status) {
83
+ return task.get("id").and_then(Value::as_str).map(str::to_string);
84
+ }
85
+ }
86
+ None
87
+ }
88
+
89
+ pub(super) fn agent_health_status_text(status: &str) -> String {
90
+ serde_json::to_value(crate::provider::agent_health_status(status))
91
+ .ok()
92
+ .and_then(|v| v.as_str().map(str::to_string))
93
+ .unwrap_or_else(|| "-".to_string())
94
+ }
95
+
96
+ /// Python `agent.get(key, '-')`:键缺失 → `-`;键存在但为 null → 打印 `None`。
97
+ pub(super) fn py_get(agent: &Value, key: &str) -> String {
98
+ match agent.get(key) {
99
+ None => "-".to_string(),
100
+ Some(Value::Null) => "None".to_string(),
101
+ Some(Value::String(s)) => s.clone(),
102
+ Some(other) => other.to_string(),
103
+ }
104
+ }
105
+
106
+ /// Python `agent.get(key) or '-'`:缺失/null/空串都落 `-`。
107
+ pub(super) fn py_get_or_dash(agent: &Value, key: &str) -> String {
108
+ match agent.get(key) {
109
+ Some(Value::String(s)) if !s.is_empty() => s.clone(),
110
+ Some(Value::Number(n)) => n.to_string(),
111
+ _ => "-".to_string(),
112
+ }
113
+ }
114
+
115
+ /// `status.approvals(workspace, agent_id)`(JSON)/`format_approvals`(人读)。
116
+ pub fn format_approvals(value: &Value) -> String {
117
+ let approvals = value
118
+ .get("approvals")
119
+ .and_then(Value::as_array)
120
+ .map(Vec::as_slice)
121
+ .unwrap_or(&[]);
122
+ if approvals.is_empty() {
123
+ return "No pending approvals.".to_string();
124
+ }
125
+ approvals
126
+ .iter()
127
+ .map(|approval| {
128
+ let agent = approval
129
+ .get("agent_id")
130
+ .and_then(Value::as_str)
131
+ .unwrap_or("-");
132
+ let kind = approval
133
+ .get("kind")
134
+ .and_then(Value::as_str)
135
+ .unwrap_or("unknown");
136
+ let prompt = approval
137
+ .get("prompt")
138
+ .and_then(Value::as_str)
139
+ .or_else(|| approval.get("subject").and_then(Value::as_str))
140
+ .unwrap_or("-");
141
+ format!("{agent}: {kind} {prompt}")
142
+ })
143
+ .collect::<Vec<_>>()
144
+ .join("\n")
145
+ }
@@ -0,0 +1,36 @@
1
+ use super::*;
2
+
3
+ pub fn inbox(
4
+ workspace: &Path,
5
+ agent: &str,
6
+ limit: usize,
7
+ since: Option<&str>,
8
+ as_json: bool,
9
+ owner_team_id: Option<&str>,
10
+ ) -> Result<Value, CliError> {
11
+ let _ = as_json;
12
+ let store = crate::message_store::MessageStore::open(workspace)
13
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
14
+ let mut messages = store
15
+ .inbox(agent, limit, owner_team_id)
16
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
17
+ if let Some(cutoff) = since.and_then(parse_rfc3339) {
18
+ messages.retain(|message| {
19
+ message
20
+ .get("created_at")
21
+ .and_then(Value::as_str)
22
+ .and_then(parse_rfc3339)
23
+ .is_some_and(|created| created >= cutoff)
24
+ });
25
+ }
26
+ Ok(json!({
27
+ "ok": true,
28
+ "agent_id": agent,
29
+ "messages": messages,
30
+ "since": since,
31
+ }))
32
+ }
33
+
34
+ pub(super) fn parse_rfc3339(value: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
35
+ chrono::DateTime::parse_from_rfc3339(value).ok()
36
+ }
@@ -0,0 +1,195 @@
1
+ use super::*;
2
+
3
+ /// 0.5.41 Slice 3 (fault-invisibility-locate.md §5): single-source
4
+ /// runtime freshness projection consumed by status/diagnose. Read-
5
+ /// only over existing sources (`coordinator_health`, heartbeat
6
+ /// sidecar, watch state); computes NO transport calls of its own.
7
+ #[derive(Default, Clone)]
8
+ pub(crate) struct RuntimeFreshness {
9
+ pub coordinator_service_available: bool,
10
+ pub host_boot_stale: bool,
11
+ pub host_boot_recorded: Option<String>,
12
+ pub host_boot_current: Option<String>,
13
+ pub provider_exited_agents: std::collections::BTreeSet<String>,
14
+ }
15
+
16
+ impl RuntimeFreshness {
17
+ pub(super) fn host_boot_stale_reason(&self) -> Option<&'static str> {
18
+ self.host_boot_stale.then_some("host_boot_mismatch")
19
+ }
20
+ }
21
+
22
+ pub(crate) fn compute_runtime_freshness(
23
+ workspace: &Path,
24
+ state: &Value,
25
+ health: &crate::coordinator::HealthReport,
26
+ ) -> RuntimeFreshness {
27
+ let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
28
+ let heartbeat = crate::coordinator::read_coordinator_heartbeat(&workspace_path);
29
+ let host_boot_recorded = heartbeat
30
+ .as_ref()
31
+ .and_then(|hb| hb.get("host_boot_id"))
32
+ .and_then(Value::as_str)
33
+ .filter(|s| !s.is_empty() && *s != "unknown")
34
+ .map(str::to_string);
35
+ let host_boot_current = crate::coordinator::probe_host_boot_id();
36
+ let host_boot_stale = match (host_boot_recorded.as_deref(), host_boot_current.as_deref()) {
37
+ (Some(recorded), Some(current)) => recorded != current,
38
+ _ => false,
39
+ };
40
+ // Collect worker-provider-exited agents from the coordinator
41
+ // abnormal_exit_watch payload. Only the typed positive provider-exit
42
+ // marker participates here: generic process/pane death diagnostics are
43
+ // not proof that the provider exited. Read from top-level
44
+ // `coordinator.abnormal_exit_watch`
45
+ // OR the team-scoped mirror `teams.<key>.coordinator...`.
46
+ let mut provider_exited_agents = std::collections::BTreeSet::new();
47
+ for path in [
48
+ "/coordinator/abnormal_exit_watch",
49
+ &format!(
50
+ "/teams/{}/coordinator/abnormal_exit_watch",
51
+ state
52
+ .get("active_team_key")
53
+ .and_then(Value::as_str)
54
+ .unwrap_or("")
55
+ ),
56
+ ] {
57
+ if let Some(watch) = state.pointer(path).and_then(Value::as_object) {
58
+ for (agent_id, entry) in watch {
59
+ let exited =
60
+ entry.get("worker_provider_exited").and_then(Value::as_bool) == Some(true);
61
+ if exited {
62
+ provider_exited_agents.insert(agent_id.clone());
63
+ }
64
+ }
65
+ }
66
+ }
67
+ RuntimeFreshness {
68
+ coordinator_service_available: health.service_available,
69
+ host_boot_stale,
70
+ host_boot_recorded,
71
+ host_boot_current,
72
+ provider_exited_agents,
73
+ }
74
+ }
75
+
76
+ pub(super) fn build_runtime_status_block(
77
+ coordinator_running: bool,
78
+ undelivered: i64,
79
+ tmux_session_missing: bool,
80
+ freshness: &RuntimeFreshness,
81
+ ) -> Value {
82
+ let mut runtime = serde_json::Map::new();
83
+ runtime.insert(
84
+ "coordinator".to_string(),
85
+ json!({"ok": coordinator_running}),
86
+ );
87
+ runtime.insert("undelivered".to_string(), json!(undelivered));
88
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): expose
89
+ // typed issues on the runtime block so `status --json --detail`
90
+ // can be scanned for `runtime_bindings_stale_after_boot` the
91
+ // same way `diagnose` surfaces it. Hint precedence: session-
92
+ // missing > host-boot-stale > coordinator-not-running-with-
93
+ // backlog — most-actionable-first.
94
+ let mut issues: Vec<Value> = Vec::new();
95
+ if freshness.host_boot_stale {
96
+ issues.push(json!({
97
+ "id": "runtime_bindings_stale_after_boot",
98
+ "recorded_host_boot_id": freshness.host_boot_recorded,
99
+ "current_host_boot_id": freshness.host_boot_current,
100
+ }));
101
+ }
102
+ if !issues.is_empty() {
103
+ runtime.insert("issues".to_string(), Value::Array(issues));
104
+ }
105
+ if tmux_session_missing {
106
+ runtime.insert(
107
+ "hint".to_string(),
108
+ json!("tmux session missing — run team-agent restart"),
109
+ );
110
+ } else if freshness.host_boot_stale {
111
+ runtime.insert(
112
+ "hint".to_string(),
113
+ json!("runtime_bindings_stale_after_boot — run team-agent restart"),
114
+ );
115
+ } else if !coordinator_running && undelivered > 0 {
116
+ runtime.insert(
117
+ "hint".to_string(),
118
+ json!(format!(
119
+ "coordinator not running with {undelivered} undelivered — run team-agent restart"
120
+ )),
121
+ );
122
+ }
123
+ Value::Object(runtime)
124
+ }
125
+
126
+ /// Whether the coordinator HealthReport reflects a running tick loop. Used by the
127
+ /// runtime block + the hint gate.
128
+ pub(super) fn coordinator_status_running(health: &crate::coordinator::HealthReport) -> bool {
129
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): runtime
130
+ // service predicate is `service_available`, not any-`Running` pid.
131
+ // A stale-pid daemon is CoordinatorHealthStatus::Stale (or Running
132
+ // with metadata_ok=false); a service-compatible newer daemon is
133
+ // Running + service_available=true — send/diagnose already use
134
+ // this same truth source, and status must agree.
135
+ health.service_available
136
+ }
137
+
138
+ /// Count of messages currently sitting in delivery-able backlog
139
+ /// (accepted/pending/queued forms — not delivered / not failed / not refused).
140
+ /// owner_team_id scope honored when present.
141
+ pub(super) fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
142
+ let expose_binary_drift = health.service_available && !health.metadata_ok;
143
+ let binary_identity_relation = health.binary_identity_relation.as_str();
144
+ let mut value = json!({
145
+ "ok": health.ok,
146
+ "status": coordinator_status_wire(health.status),
147
+ "pid": health.pid.map(|p| p.get()),
148
+ "metadata": health.metadata.map(|m| json!({
149
+ "pid": m.pid.get(),
150
+ "protocol_version": m.protocol_version,
151
+ "message_store_schema_version": m.message_store_schema_version,
152
+ "binary_path": m.binary_path,
153
+ "binary_version": m.binary_version,
154
+ "source": m.source,
155
+ "updated_at": m.updated_at,
156
+ })),
157
+ "metadata_ok": health.metadata_ok,
158
+ "metadata_mismatch_reason": health.metadata_mismatch_reason,
159
+ "binary_path": health.current_binary_identity.binary_path,
160
+ "binary_version": health.current_binary_identity.binary_version,
161
+ "schema_ok": health.schema.ok,
162
+ "schema_error": health.schema.error.map(|e| format!("{e:?}")),
163
+ "schema": {
164
+ "message_store_schema_version": health.schema.schema_version,
165
+ },
166
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3):
167
+ // `service_available` is now always exposed alongside `ok`
168
+ // and `status` so status/diagnose consumers can share one
169
+ // service-availability truth without keying on the legacy
170
+ // `expose_binary_drift` conditional (that path only fired
171
+ // for the newer-daemon-preserved shape). `binary_identity_
172
+ // relation` moves to always-on for the same reason — RED3
173
+ // scans the summary for `daemon_newer_than_caller`.
174
+ "service_available": health.service_available,
175
+ "binary_identity_relation": binary_identity_relation,
176
+ });
177
+ if expose_binary_drift {
178
+ if let Some(obj) = value.as_object_mut() {
179
+ obj.insert("wire_metadata_ok".to_string(), Value::Bool(true));
180
+ obj.insert("binary_identity_ok".to_string(), Value::Bool(false));
181
+ }
182
+ }
183
+ value
184
+ }
185
+
186
+ pub(super) fn coordinator_status_wire(
187
+ status: crate::coordinator::CoordinatorHealthStatus,
188
+ ) -> &'static str {
189
+ match status {
190
+ crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
191
+ crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
192
+ crate::coordinator::CoordinatorHealthStatus::Running => "running",
193
+ crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
194
+ }
195
+ }
@@ -0,0 +1,181 @@
1
+ use super::agents::{enrich_agents, tmux_session_present};
2
+ use super::compact::compact_status;
3
+ use super::runtime::{
4
+ build_runtime_status_block, compute_runtime_freshness, coordinator_health_value,
5
+ coordinator_status_running,
6
+ };
7
+ use super::store::{
8
+ agent_health, count_undelivered_backlog, latest_result_summaries, message_counts,
9
+ pending_leader_notifications, queued_messages, result_counts,
10
+ };
11
+ use crate::cli::CliError;
12
+ use crate::state::projection::OwnerTeamResolution;
13
+ use serde_json::{json, Value};
14
+ use std::path::Path;
15
+
16
+ pub(crate) struct RuntimeSnapshot {
17
+ full: Value,
18
+ }
19
+
20
+ impl RuntimeSnapshot {
21
+ pub(crate) fn assemble(
22
+ workspace: &Path,
23
+ state: &Value,
24
+ owner_team_id: Option<&str>,
25
+ ) -> Result<Self, CliError> {
26
+ let resolved_owner_team_id = resolve_status_owner_team(workspace, owner_team_id)?;
27
+ let owner_team_id = resolved_owner_team_id.as_deref().or(owner_team_id);
28
+ let health = crate::coordinator::coordinator_health(
29
+ &crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
30
+ );
31
+ let store = crate::message_store::MessageStore::open(workspace)
32
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
33
+ let conn = crate::db::schema::open_db(store.db_path())
34
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
35
+ // B-5 / 036b N38 explicable — status 出口 runtime 块:把 coordinator_health
36
+ // (现状)+ undelivered backlog count 一起暴露;coordinator not running ∧
37
+ // backlog>0 才挂 down-hint(anti-nag)。auto-recovery 不做(user 已裁)。
38
+ let coordinator_running = coordinator_status_running(&health);
39
+ let undelivered_backlog = count_undelivered_backlog(&conn, owner_team_id)?;
40
+ let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
41
+ let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
42
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): resolve
43
+ // RuntimeFreshness once and thread it through the runtime block
44
+ // and per-agent enrichment. This is the single-source read that
45
+ // makes host-boot / coordinator / provider-exit staleness win
46
+ // over cached state.agents and DB agent_health.
47
+ let freshness = compute_runtime_freshness(workspace, state, &health);
48
+ let runtime_block = build_runtime_status_block(
49
+ coordinator_running,
50
+ undelivered_backlog,
51
+ !tmux_present,
52
+ &freshness,
53
+ );
54
+ let agents = enrich_agents(workspace, state, tmux_present, &freshness);
55
+ let tasks = state.get("tasks").cloned().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 {
62
+ "external"
63
+ } else {
64
+ "managed"
65
+ };
66
+ let leader_attach_command = if is_external_leader {
67
+ None
68
+ } else {
69
+ let window_name = state
70
+ .pointer("/leader_receiver/window_name")
71
+ .and_then(Value::as_str)
72
+ .unwrap_or("leader");
73
+ session_name.as_str().and_then(|session| {
74
+ // Bug #7 (gate review §6): build the attach command from the
75
+ // SAME endpoint the readiness probe uses (state's persisted
76
+ // tmux_endpoint/tmux_socket), so the printed command matches
77
+ // where the session actually lives.
78
+ crate::tmux_backend::attach_command_for_runtime_state_or_workspace(
79
+ workspace,
80
+ Some(state),
81
+ &crate::transport::SessionName::new(session.to_string()),
82
+ window_name,
83
+ )
84
+ })
85
+ };
86
+ let mut readiness_state = state.clone();
87
+ if let Some(obj) = readiness_state.as_object_mut() {
88
+ obj.insert(
89
+ "tmux_session_present".to_string(),
90
+ serde_json::json!(tmux_present),
91
+ );
92
+ }
93
+ let readiness = crate::cli::diagnose::wait_readiness(&readiness_state);
94
+ let full = crate::redaction::redact_external_value(&json!({
95
+ "ok": true,
96
+ "team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
97
+ "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
98
+ "leader_topology": leader_topology,
99
+ "is_external_leader": is_external_leader,
100
+ "leader_attach_command": leader_attach_command,
101
+ "leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
102
+ "tmux_session_present": tmux_present,
103
+ "all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
104
+ "all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
105
+ "all_resumable_have_session": readiness.get("all_resumable_have_session").cloned().unwrap_or(Value::Bool(true)),
106
+ "session_capture_complete": readiness.get("session_capture_complete").cloned().unwrap_or(Value::Bool(true)),
107
+ "session_capture_incomplete": readiness.get("session_capture_incomplete").cloned().unwrap_or(Value::Bool(false)),
108
+ "incomplete_session_capture_agents": readiness.get("incomplete_session_capture_agents").cloned().unwrap_or_else(|| json!([])),
109
+ "pending_session_agent_ids": readiness.get("pending_session_agent_ids").cloned().unwrap_or_else(|| json!([])),
110
+ "leader_receiver": leader_receiver,
111
+ "teams": state.get("teams").cloned().unwrap_or_else(|| json!({})),
112
+ "agents": agents,
113
+ "agent_health": agent_health(&conn, owner_team_id)?,
114
+ "tasks": tasks,
115
+ "messages": message_counts(&conn, owner_team_id)?,
116
+ "queued_messages": queued_messages(&conn, owner_team_id, 8)?,
117
+ "pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
118
+ "results": result_counts(&conn, owner_team_id)?,
119
+ "latest_results": latest_result_summaries(&store, owner_team_id)?,
120
+ "readiness": readiness,
121
+ "coordinator": coordinator_health_value(health),
122
+ "runtime": runtime_block,
123
+ "reminder": crate::cli::STATUS_REMINDER,
124
+ "last_events": Value::Array(
125
+ crate::event_log::EventLog::new(workspace)
126
+ .tail(10)
127
+ .map_err(|e| CliError::Runtime(e.to_string()))?,
128
+ ),
129
+ }));
130
+ Ok(Self { full })
131
+ }
132
+
133
+ pub(crate) fn full(&self) -> &Value {
134
+ &self.full
135
+ }
136
+
137
+ pub(crate) fn into_full(self) -> Value {
138
+ self.full
139
+ }
140
+
141
+ pub(crate) fn compact(&self) -> Value {
142
+ compact_status(self.full.clone())
143
+ }
144
+ }
145
+
146
+ pub(super) fn read_runtime_state(workspace: &Path) -> Value {
147
+ crate::state::repository::StateRepository::new(workspace)
148
+ .load_workspace_if_exists_without_migrations()
149
+ .ok()
150
+ .flatten()
151
+ .unwrap_or_else(|| json!({}))
152
+ }
153
+
154
+ pub(super) fn resolve_status_owner_team(
155
+ workspace: &Path,
156
+ owner_team_id: Option<&str>,
157
+ ) -> Result<Option<String>, CliError> {
158
+ let Some(requested) = owner_team_id.filter(|team| !team.is_empty()) else {
159
+ return Ok(None);
160
+ };
161
+ let state = read_runtime_state(workspace);
162
+ match crate::state::projection::resolve_owner_team_id(&state, requested) {
163
+ OwnerTeamResolution::Canonical(canonical) => Ok(Some(canonical)),
164
+ OwnerTeamResolution::LegacyAlias {
165
+ requested,
166
+ canonical,
167
+ } => {
168
+ let log = crate::event_log::EventLog::new(workspace);
169
+ crate::messaging::delivery::normalize_owner_team_id_rows(
170
+ workspace,
171
+ &requested,
172
+ &canonical,
173
+ None,
174
+ Some(&log),
175
+ )
176
+ .map_err(CliError::from)?;
177
+ Ok(Some(canonical))
178
+ }
179
+ OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => Ok(None),
180
+ }
181
+ }