@team-agent/installer 0.3.0 → 0.3.2

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 (39) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +38 -7
  4. package/crates/team-agent/src/cli/emit.rs +182 -54
  5. package/crates/team-agent/src/cli/mod.rs +703 -35
  6. package/crates/team-agent/src/cli/status_port.rs +170 -44
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  8. package/crates/team-agent/src/cli/types.rs +1 -0
  9. package/crates/team-agent/src/coordinator/health.rs +130 -0
  10. package/crates/team-agent/src/leader/lease.rs +23 -2
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +2 -0
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
  14. package/crates/team-agent/src/leader/tests/idle.rs +1 -0
  15. package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
  16. package/crates/team-agent/src/leader/types.rs +2 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +554 -65
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
  21. package/crates/team-agent/src/lifecycle/restart.rs +20 -0
  22. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
  23. package/crates/team-agent/src/lifecycle/types.rs +25 -0
  24. package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
  25. package/crates/team-agent/src/mcp_server/wire.rs +81 -1
  26. package/crates/team-agent/src/messaging/delivery.rs +574 -12
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
  28. package/crates/team-agent/src/messaging/mod.rs +1 -1
  29. package/crates/team-agent/src/messaging/results.rs +218 -49
  30. package/crates/team-agent/src/messaging/send.rs +15 -19
  31. package/crates/team-agent/src/provider/adapter.rs +95 -10
  32. package/crates/team-agent/src/provider/helpers.rs +10 -1
  33. package/crates/team-agent/src/state/identity.rs +3 -0
  34. package/crates/team-agent/src/state/persist.rs +113 -1
  35. package/crates/team-agent/src/state/projection.rs +127 -3
  36. package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
  37. package/crates/team-agent/src/tmux_backend.rs +124 -12
  38. package/npm/install.mjs +29 -7
  39. package/package.json +4 -4
@@ -10,7 +10,7 @@ use crate::model::ids::{OwnerEpoch, TaskId};
10
10
  use crate::transport::Transport;
11
11
 
12
12
  use super::helpers::MessageStatusShadow;
13
- use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
+ use super::{DeliveryOutcome, DeliveryStatus, MessagingError};
14
14
 
15
15
  /// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
16
16
  /// caller(send_message(to=leader) / report_result / request_human / idle reminder /
@@ -105,40 +105,6 @@ pub fn send_to_leader_receiver(
105
105
  "result_id": result_id,
106
106
  }),
107
107
  )?;
108
- // I-4: unbound leader pane → rebind_required (not the legacy diagnostic success).
109
- // The row IS persisted (caller / rebind audit / future replay need the message_id),
110
- // but marked `failed` so `deliver_pending_messages` does NOT pick it up — pane stays
111
- // untouched, ok=false, channel=rebind_required. #231 auto-reclaim's
112
- // requeue_after_claim_leader flips this row back to `accepted` after rebind, and
113
- // deliver_pending replays it through the same pipeline (same message_id, exactly once).
114
- let pane_attached = leader_pane_id(state)
115
- .filter(|pane_id| leader_pane_is_live(workspace, pane_id))
116
- .is_some();
117
- if !pane_attached {
118
- let _ = store.mark(&message_id, "failed", Some("leader_not_attached"));
119
- event_log.write(
120
- "leader_receiver.delivery_blocked",
121
- serde_json::json!({
122
- "message_id": message_id,
123
- "sender": sender,
124
- "leader_id": leader_id,
125
- "owner_team_id": owner_team,
126
- "reason": "leader_not_attached",
127
- "channel": "rebind_required",
128
- "action": "run team-agent claim-leader or team-agent takeover",
129
- }),
130
- )?;
131
- return Ok(DeliveryOutcome {
132
- ok: false,
133
- status: DeliveryStatus::Blocked,
134
- message_status: MessageStatusShadow("blocked".to_string()),
135
- message_id: Some(message_id),
136
- verification: None,
137
- stage: None,
138
- reason: Some(DeliveryRefusal::LeaderNotAttached),
139
- channel: Some("rebind_required".to_string()),
140
- });
141
- }
142
108
  event_log.write(
143
109
  "leader_receiver.queued",
144
110
  serde_json::json!({
@@ -221,6 +187,15 @@ pub fn claim_leader_receiver(
221
187
  copy_candidate_field(receiver, candidate, "pane_id");
222
188
  copy_candidate_field(receiver, candidate, "provider");
223
189
  copy_candidate_field(receiver, candidate, "leader_session_uuid");
190
+ if let Some(socket) = candidate
191
+ .get("tmux_socket")
192
+ .and_then(Value::as_str)
193
+ .filter(|socket| std::path::Path::new(socket).is_absolute())
194
+ .map(str::to_string)
195
+ .or_else(crate::tmux_backend::socket_name_from_tmux_env)
196
+ {
197
+ receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
198
+ }
224
199
  }
225
200
  crate::state::persist::save_runtime_state(workspace, state)?;
226
201
  event_log.write(
@@ -310,10 +285,17 @@ fn leader_session_uuid(state: &Value) -> Option<&str> {
310
285
 
311
286
  pub(crate) fn leader_pane_bound_but_not_live(workspace: &Path, state: &Value) -> bool {
312
287
  leader_pane_id(state)
313
- .is_some_and(|pane_id| !leader_pane_is_live(workspace, pane_id))
288
+ .is_some_and(|pane_id| !leader_pane_is_live(workspace, state, pane_id))
314
289
  }
315
290
 
316
- fn leader_pane_is_live(workspace: &Path, pane_id: &str) -> bool {
291
+ fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
292
+ if let Some(socket) = leader_tmux_socket(state) {
293
+ return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket)
294
+ .list_targets()
295
+ .unwrap_or_default()
296
+ .iter()
297
+ .any(|target| target.pane_id.as_str() == pane_id);
298
+ }
317
299
  let mut targets = crate::tmux_backend::TmuxBackend::for_workspace(workspace)
318
300
  .list_targets()
319
301
  .unwrap_or_default();
@@ -329,6 +311,13 @@ fn leader_pane_id(state: &Value) -> Option<&str> {
329
311
  leader_record_field(state, "pane_id").and_then(Value::as_str)
330
312
  }
331
313
 
314
+ fn leader_tmux_socket(state: &Value) -> Option<&str> {
315
+ leader_record_field(state, "tmux_socket")
316
+ .and_then(Value::as_str)
317
+ .filter(|socket| !socket.is_empty())
318
+ .filter(|socket| std::path::Path::new(socket).is_absolute())
319
+ }
320
+
332
321
  fn copy_candidate_field(
333
322
  out: &mut serde_json::Map<String, Value>,
334
323
  candidate: &Value,
@@ -86,7 +86,7 @@ pub use leader_receiver::{
86
86
  claim_leader_receiver, mirror_peer_message_to_leader, send_to_leader_receiver,
87
87
  };
88
88
  pub use peers::allow_peer_talk;
89
- pub use results::{collect, collect_results_and_notify_watchers, report_result};
89
+ pub use results::{collect, collect_for_team, collect_results_and_notify_watchers, report_result};
90
90
  pub use scheduler::{detect_stuck_agents, fire_due_scheduled_events, stuck_cancel, stuck_list};
91
91
  pub use selftest::{evaluate_idle_behavior, run_comms_selftest, CommsSelftestDriver};
92
92
  pub use send::{apply_worker_sender_bypass, send_message, session_drift_refusal, MessageTarget, SendOptions};
@@ -10,6 +10,7 @@ use crate::message_store::MessageStore;
10
10
  use super::helpers::{next_result_id, required_str, validate_result_envelope};
11
11
  use super::types::SEND_RETRY_MAX_ATTEMPTS;
12
12
  use crate::model::ids::TaskId;
13
+ use crate::state::projection::OwnerTeamResolution;
13
14
  use super::watchers::retry_result_deliveries;
14
15
  use super::MessagingError;
15
16
 
@@ -19,26 +20,65 @@ pub fn collect(
19
20
  workspace: &Path,
20
21
  result_file: Option<&Path>,
21
22
  ensure_coordinator: bool,
23
+ ) -> Result<serde_json::Value, MessagingError> {
24
+ collect_scoped(workspace, result_file, ensure_coordinator, None)
25
+ }
26
+
27
+ pub fn collect_for_team(
28
+ workspace: &Path,
29
+ result_file: Option<&Path>,
30
+ ensure_coordinator: bool,
31
+ owner_team_id: Option<&str>,
32
+ ) -> Result<serde_json::Value, MessagingError> {
33
+ collect_scoped(workspace, result_file, ensure_coordinator, owner_team_id)
34
+ }
35
+
36
+ fn collect_scoped(
37
+ workspace: &Path,
38
+ result_file: Option<&Path>,
39
+ ensure_coordinator: bool,
40
+ owner_team_id: Option<&str>,
22
41
  ) -> Result<serde_json::Value, MessagingError> {
23
42
  let _ = ensure_coordinator;
24
43
  let paths = collect_paths(workspace)?;
25
- let spec_path = paths.spec_workspace.join("team.spec.yaml");
44
+ let log = EventLog::new(&paths.run_workspace);
45
+ let resolved_owner_team_id = match owner_team_id.filter(|team| !team.is_empty()) {
46
+ Some(team) => Some(resolve_owner_team_for_read(&paths.run_workspace, team, Some(&log))?),
47
+ None => None,
48
+ };
49
+ let owner_team_id = resolved_owner_team_id.as_deref();
50
+ let mut state = match owner_team_id {
51
+ Some(team) => crate::state::projection::select_runtime_state(&paths.run_workspace, Some(team))?,
52
+ None => crate::state::persist::load_runtime_state(&paths.run_workspace)?,
53
+ };
54
+ let spec_workspace = owner_team_id
55
+ .and_then(|_| state_spec_workspace_from_value(&state))
56
+ .unwrap_or_else(|| paths.spec_workspace.clone());
57
+ let spec_path = spec_workspace.join("team.spec.yaml");
26
58
  if !spec_path.exists() {
27
59
  return Err(MessagingError::Validation(format!("Cannot read {}", spec_path.display())));
28
60
  }
29
61
  let store = MessageStore::open(&paths.run_workspace)?;
30
62
  let conn = crate::db::schema::open_db(store.db_path())?;
31
63
  if let Some(path) = result_file {
32
- ingest_result_file(&conn, path)?;
64
+ ingest_result_file(&conn, path, owner_team_id)?;
33
65
  }
34
- let mut stmt = conn.prepare(
35
- "select result_id, task_id, agent_id, envelope, status, created_at
36
- from results
37
- where status not in ('collected', 'invalid')
38
- order by created_at, result_id",
39
- )?;
40
- let rows = stmt
41
- .query_map([], |row| {
66
+ let sql = match owner_team_id {
67
+ Some(_) => {
68
+ "select result_id, task_id, agent_id, envelope, status, created_at
69
+ from results
70
+ where status not in ('collected', 'invalid') and owner_team_id = ?1
71
+ order by created_at, result_id"
72
+ }
73
+ None => {
74
+ "select result_id, task_id, agent_id, envelope, status, created_at
75
+ from results
76
+ where status not in ('collected', 'invalid')
77
+ order by created_at, result_id"
78
+ }
79
+ };
80
+ let mut stmt = conn.prepare(sql)?;
81
+ let row_mapper = |row: &rusqlite::Row<'_>| {
42
82
  Ok(StoredResult {
43
83
  result_id: row.get(0)?,
44
84
  task_id: row.get(1)?,
@@ -47,16 +87,18 @@ pub fn collect(
47
87
  status: row.get(4)?,
48
88
  created_at: row.get(5)?,
49
89
  })
50
- })?
90
+ };
91
+ let rows = match owner_team_id {
92
+ Some(team) => stmt.query_map(params![team], row_mapper),
93
+ None => stmt.query_map([], row_mapper),
94
+ }?
51
95
  .collect::<Result<Vec<_>, _>>()?;
52
96
  drop(stmt);
53
97
 
54
- let mut state = crate::state::persist::load_runtime_state(&paths.run_workspace)?;
55
98
  let mut collected = Vec::new();
56
99
  let mut collected_results = Vec::new();
57
100
  let mut invalid_results = Vec::new();
58
101
  let mut state_dirty = false;
59
- let log = EventLog::new(&paths.run_workspace);
60
102
  for row in rows {
61
103
  let envelope: serde_json::Value = match serde_json::from_str(&row.envelope) {
62
104
  Ok(envelope) => envelope,
@@ -83,7 +125,7 @@ pub fn collect(
83
125
  }
84
126
  let scope = if task_exists(&state, &row.task_id) {
85
127
  "task"
86
- } else if is_message_scoped_result(&conn, &row.task_id, &row.agent_id)? {
128
+ } else if is_message_scoped_result(&conn, &row.task_id, &row.agent_id, owner_team_id)? {
87
129
  "message"
88
130
  } else {
89
131
  record_invalid_result(
@@ -95,10 +137,20 @@ pub fn collect(
95
137
  )?;
96
138
  continue;
97
139
  };
98
- conn.execute(
99
- "update results set status = 'collected' where result_id = ?1",
100
- params![row.result_id.as_str()],
101
- )?;
140
+ match owner_team_id {
141
+ Some(team) => {
142
+ conn.execute(
143
+ "update results set status = 'collected' where result_id = ?1 and owner_team_id = ?2",
144
+ params![row.result_id.as_str(), team],
145
+ )?;
146
+ }
147
+ None => {
148
+ conn.execute(
149
+ "update results set status = 'collected' where result_id = ?1",
150
+ params![row.result_id.as_str()],
151
+ )?;
152
+ }
153
+ }
102
154
  if scope == "task" {
103
155
  mark_task_done(&mut state, &row.task_id, &row.result_id);
104
156
  state_dirty = true;
@@ -129,9 +181,13 @@ pub fn collect(
129
181
  collected_results.push(summary);
130
182
  }
131
183
  if state_dirty {
132
- crate::state::persist::save_runtime_state(&paths.run_workspace, &state)?;
184
+ if owner_team_id.is_some() {
185
+ crate::state::projection::save_team_scoped_state(&paths.run_workspace, &state)?;
186
+ } else {
187
+ crate::state::persist::save_runtime_state(&paths.run_workspace, &state)?;
188
+ }
133
189
  }
134
- let counts = result_counts(&conn)?;
190
+ let counts = result_counts(&conn, owner_team_id)?;
135
191
  Ok(serde_json::json!({
136
192
  "ok": invalid_results.is_empty(),
137
193
  "collected": collected,
@@ -139,7 +195,7 @@ pub fn collect(
139
195
  "delivered_messages": [],
140
196
  "invalid_results": invalid_results,
141
197
  "results": counts,
142
- "state_file": paths.spec_workspace.join("team_state.md").to_string_lossy().to_string(),
198
+ "state_file": spec_workspace.join("team_state.md").to_string_lossy().to_string(),
143
199
  "coordinator": {
144
200
  "ok": false,
145
201
  "status": "not_required",
@@ -147,12 +203,48 @@ pub fn collect(
147
203
  }))
148
204
  }
149
205
 
206
+ fn resolve_owner_team_for_read(
207
+ workspace: &Path,
208
+ requested: &str,
209
+ event_log: Option<&EventLog>,
210
+ ) -> Result<String, MessagingError> {
211
+ let state = crate::state::persist::load_runtime_state(workspace)?;
212
+ match crate::state::projection::resolve_owner_team_id(&state, requested) {
213
+ OwnerTeamResolution::Canonical(canonical) => Ok(canonical),
214
+ OwnerTeamResolution::LegacyAlias { requested, canonical } => {
215
+ crate::messaging::delivery::normalize_owner_team_id_rows(
216
+ workspace,
217
+ &requested,
218
+ &canonical,
219
+ None,
220
+ event_log,
221
+ )?;
222
+ Ok(canonical)
223
+ }
224
+ OwnerTeamResolution::Unresolved { requested } => {
225
+ Err(MessagingError::Routing(format!("owner_team_unresolved: {requested}")))
226
+ }
227
+ OwnerTeamResolution::Ambiguous { requested, matches } => {
228
+ Err(MessagingError::Routing(format!(
229
+ "owner_team_ambiguous: {requested} matches {}",
230
+ matches.join(",")
231
+ )))
232
+ }
233
+ }
234
+ }
235
+
150
236
  struct CollectPaths {
151
237
  run_workspace: PathBuf,
152
238
  spec_workspace: PathBuf,
153
239
  }
154
240
 
155
241
  fn collect_paths(workspace: &Path) -> Result<CollectPaths, MessagingError> {
242
+ if collect_input_has_no_local_team_context(workspace) {
243
+ return Ok(CollectPaths {
244
+ run_workspace: workspace.to_path_buf(),
245
+ spec_workspace: workspace.to_path_buf(),
246
+ });
247
+ }
156
248
  let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
157
249
  .map_err(|e| MessagingError::Routing(e.to_string()))?;
158
250
  let spec_workspace = if workspace.join("team.spec.yaml").exists() {
@@ -168,8 +260,24 @@ fn collect_paths(workspace: &Path) -> Result<CollectPaths, MessagingError> {
168
260
  })
169
261
  }
170
262
 
263
+ fn collect_input_has_no_local_team_context(workspace: &Path) -> bool {
264
+ !workspace.join("team.spec.yaml").exists()
265
+ && !workspace.join(".team").exists()
266
+ && !crate::state::persist::runtime_state_path(workspace).exists()
267
+ && workspace.file_name().and_then(|s| s.to_str()) != Some(".team")
268
+ && workspace
269
+ .parent()
270
+ .and_then(|p| p.file_name())
271
+ .and_then(|s| s.to_str())
272
+ != Some(".team")
273
+ }
274
+
171
275
  fn state_spec_workspace(run_workspace: &Path) -> Option<PathBuf> {
172
276
  let state = crate::state::persist::load_runtime_state(run_workspace).ok()?;
277
+ state_spec_workspace_from_value(&state)
278
+ }
279
+
280
+ fn state_spec_workspace_from_value(state: &serde_json::Value) -> Option<PathBuf> {
173
281
  if let Some(spec_path) = state.get("spec_path").and_then(serde_json::Value::as_str) {
174
282
  return PathBuf::from(spec_path).parent().map(Path::to_path_buf);
175
283
  }
@@ -200,7 +308,11 @@ fn record_invalid_result(
200
308
  Ok(())
201
309
  }
202
310
 
203
- fn ingest_result_file(conn: &rusqlite::Connection, path: &Path) -> Result<(), MessagingError> {
311
+ fn ingest_result_file(
312
+ conn: &rusqlite::Connection,
313
+ path: &Path,
314
+ owner_team_id: Option<&str>,
315
+ ) -> Result<(), MessagingError> {
204
316
  let raw = std::fs::read_to_string(path)?;
205
317
  let mut envelope: serde_json::Value = serde_json::from_str(&raw)?;
206
318
  validate_result_envelope(&envelope)?;
@@ -226,7 +338,7 @@ fn ingest_result_file(conn: &rusqlite::Connection, path: &Path) -> Result<(), Me
226
338
  agent_id,
227
339
  &envelope.to_string(),
228
340
  status,
229
- None,
341
+ owner_team_id,
230
342
  )?;
231
343
  Ok(())
232
344
  }
@@ -273,39 +385,55 @@ fn is_message_scoped_result(
273
385
  conn: &rusqlite::Connection,
274
386
  task_id: &str,
275
387
  agent_id: &str,
388
+ owner_team_id: Option<&str>,
276
389
  ) -> Result<bool, MessagingError> {
277
390
  if !task_id.starts_with("msg_") {
278
391
  return Ok(false);
279
392
  }
280
- let count: i64 = conn.query_row(
281
- "select count(*) from messages where message_id = ?1 and recipient = ?2",
282
- params![task_id, agent_id],
283
- |row| row.get(0),
284
- )?;
393
+ let count: i64 = match owner_team_id {
394
+ Some(team) => conn.query_row(
395
+ "select count(*) from messages where message_id = ?1 and recipient = ?2 and owner_team_id = ?3",
396
+ params![task_id, agent_id, team],
397
+ |row| row.get(0),
398
+ )?,
399
+ None => conn.query_row(
400
+ "select count(*) from messages where message_id = ?1 and recipient = ?2",
401
+ params![task_id, agent_id],
402
+ |row| row.get(0),
403
+ )?,
404
+ };
285
405
  Ok(count > 0)
286
406
  }
287
407
 
288
- fn result_counts(conn: &rusqlite::Connection) -> Result<serde_json::Value, MessagingError> {
289
- let total: i64 = conn.query_row("select count(*) from results", [], |row| row.get(0))?;
290
- let collected: i64 = conn.query_row(
291
- "select count(*) from results where status = 'collected'",
292
- [],
293
- |row| row.get(0),
294
- )?;
295
- let invalid: i64 = conn.query_row(
296
- "select count(*) from results where status = 'invalid'",
297
- [],
298
- |row| row.get(0),
299
- )?;
408
+ fn result_counts(
409
+ conn: &rusqlite::Connection,
410
+ owner_team_id: Option<&str>,
411
+ ) -> Result<serde_json::Value, MessagingError> {
412
+ let total: i64 = count_results(conn, owner_team_id, None)?;
413
+ let collected: i64 = count_results(conn, owner_team_id, Some("collected"))?;
414
+ let invalid: i64 = count_results(conn, owner_team_id, Some("invalid"))?;
300
415
  let uncollected = total - collected - invalid;
301
416
  let mut by_status = serde_json::Map::new();
302
- let mut stmt = conn.prepare(
303
- "select status, count(*) from results
304
- where status not in ('collected', 'invalid')
305
- group by status
306
- order by status",
307
- )?;
308
- let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?;
417
+ let sql = match owner_team_id {
418
+ Some(_) => {
419
+ "select status, count(*) from results
420
+ where status not in ('collected', 'invalid') and owner_team_id = ?1
421
+ group by status
422
+ order by status"
423
+ }
424
+ None => {
425
+ "select status, count(*) from results
426
+ where status not in ('collected', 'invalid')
427
+ group by status
428
+ order by status"
429
+ }
430
+ };
431
+ let mut stmt = conn.prepare(sql)?;
432
+ let row_mapper = |row: &rusqlite::Row<'_>| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?));
433
+ let rows = match owner_team_id {
434
+ Some(team) => stmt.query_map(params![team], row_mapper),
435
+ None => stmt.query_map([], row_mapper),
436
+ }?;
309
437
  for row in rows {
310
438
  let (status, count) = row?;
311
439
  by_status.insert(status, serde_json::Value::Number(count.into()));
@@ -319,6 +447,31 @@ fn result_counts(conn: &rusqlite::Connection) -> Result<serde_json::Value, Messa
319
447
  }))
320
448
  }
321
449
 
450
+ fn count_results(
451
+ conn: &rusqlite::Connection,
452
+ owner_team_id: Option<&str>,
453
+ status: Option<&str>,
454
+ ) -> Result<i64, MessagingError> {
455
+ match (owner_team_id, status) {
456
+ (Some(team), Some(status)) => Ok(conn.query_row(
457
+ "select count(*) from results where owner_team_id = ?1 and status = ?2",
458
+ params![team, status],
459
+ |row| row.get(0),
460
+ )?),
461
+ (Some(team), None) => Ok(conn.query_row(
462
+ "select count(*) from results where owner_team_id = ?1",
463
+ params![team],
464
+ |row| row.get(0),
465
+ )?),
466
+ (None, Some(status)) => Ok(conn.query_row(
467
+ "select count(*) from results where status = ?1",
468
+ params![status],
469
+ |row| row.get(0),
470
+ )?),
471
+ (None, None) => Ok(conn.query_row("select count(*) from results", [], |row| row.get(0))?),
472
+ }
473
+ }
474
+
322
475
  /// `report_result` (`results.py:191`):worker 报结果 —— 校验 envelope、存 result、ack 任务消息、
323
476
  /// **排队** send 事件通知 leader、推进 orchestrator (软依赖,失败仅记 `orchestrator.advance_skipped`)。
324
477
  /// MCP `report_result` 工具调。
@@ -394,7 +547,7 @@ pub fn report_result(
394
547
  let content = format_report_result_notification(&result_id, task_id, agent_id, status, envelope);
395
548
  let state = crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
396
549
  let event_log = EventLog::new(workspace);
397
- let outcome = super::leader_receiver::send_to_leader_receiver(
550
+ let mut outcome = super::leader_receiver::send_to_leader_receiver(
398
551
  workspace,
399
552
  &state,
400
553
  "leader",
@@ -405,10 +558,26 @@ pub fn report_result(
405
558
  Some(&result_id),
406
559
  &event_log,
407
560
  )?;
561
+ if matches!(outcome.status, crate::messaging::DeliveryStatus::Queued) {
562
+ if let Some(message_id) = outcome.message_id.clone() {
563
+ let store = MessageStore::open(workspace)?;
564
+ let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
565
+ outcome = super::delivery::deliver_pending_message(
566
+ workspace,
567
+ &store,
568
+ &transport,
569
+ &message_id,
570
+ &event_log,
571
+ &state,
572
+ )?;
573
+ }
574
+ }
408
575
  let leader_notified = outcome.ok;
409
576
  let notification_status_wire = if outcome.ok {
410
577
  "delivered"
411
- } else if matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked) {
578
+ } else if outcome.channel.as_deref() == Some("rebind_required")
579
+ || matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked)
580
+ {
412
581
  "rebind_required"
413
582
  } else {
414
583
  "refused"
@@ -8,7 +8,7 @@ use crate::model::enums::PaneLiveness;
8
8
  use crate::transport::{PaneId, Transport};
9
9
 
10
10
  use super::helpers::{status_wire, MessageStatusShadow};
11
- use super::leader_receiver::{leader_pane_bound_but_not_live, send_to_leader_receiver};
11
+ use super::leader_receiver::send_to_leader_receiver;
12
12
  use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
13
 
14
14
  /// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
@@ -86,25 +86,9 @@ pub fn send_message(
86
86
  .map(|team| crate::state::projection::project_top_level_view(&raw_state, team.as_str()))
87
87
  .unwrap_or_else(|| raw_state.clone());
88
88
  backfill_leader_binding_for_delivery_view(&mut state, &raw_state);
89
- let target_is_leader = matches!(target, MessageTarget::Single(target) if target == "leader");
90
- if target_is_leader
91
- && sender_is_leader(&state, &opts.sender)
92
- && leader_pane_bound_but_not_live(workspace, &state)
93
- {
94
- event_log.write(
95
- "leader_receiver.delivery_blocked",
96
- serde_json::json!({
97
- "sender": opts.sender,
98
- "reason": "leader_not_attached",
99
- "channel": "rebind_required",
100
- "action": "run team-agent claim-leader or team-agent takeover",
101
- }),
102
- )?;
103
- return Ok(rebind_required_outcome(None));
104
- }
105
89
  let recipient = match target {
106
90
  MessageTarget::Single(target) if target == "leader" => {
107
- return send_to_leader_receiver(
91
+ let outcome = send_to_leader_receiver(
108
92
  workspace,
109
93
  &state,
110
94
  "leader",
@@ -114,7 +98,19 @@ pub fn send_message(
114
98
  opts.requires_ack,
115
99
  None,
116
100
  &event_log,
117
- );
101
+ )?;
102
+ if matches!(outcome.status, DeliveryStatus::Queued) && owner_pane_is_dead(&state) {
103
+ if let Some(message_id) = outcome.message_id.clone() {
104
+ let team_key = owner_gate_hint_team_key(&state);
105
+ if !explicit_claim_applied(workspace, &team_key, "") {
106
+ return Ok(rebind_required_outcome_with_verification(
107
+ Some(message_id),
108
+ format!("team-agent claim-leader --team {team_key}"),
109
+ ));
110
+ }
111
+ }
112
+ }
113
+ return Ok(outcome);
118
114
  }
119
115
  MessageTarget::Single(target) if target.is_empty() => {
120
116
  return Ok(refused_outcome(DeliveryRefusal::UnknownRecipient));