@team-agent/installer 0.5.42 → 0.5.44

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