@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
@@ -7,15 +7,19 @@ use rusqlite::{params, OptionalExtension};
7
7
 
8
8
  use crate::event_log::EventLog;
9
9
  use crate::message_store::MessageStore;
10
- use crate::model::enums::Provider;
10
+ use crate::model::enums::{PaneLiveness, Provider};
11
11
  use crate::model::ids::TeamKey;
12
- use crate::transport::{InjectPayload, Key, PaneId, SessionName, Target, Transport, WindowName};
12
+ use crate::transport::{
13
+ submit_verification_wire, InjectPayload, InjectReport, Key, PaneId, SessionName,
14
+ SubmitVerification, Target, Transport, WindowName,
15
+ };
13
16
 
14
17
  use super::helpers::{message_exists, MessageStatusShadow};
15
18
  use super::{
16
19
  DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError,
17
20
  PaneWidthQuery, TrustRetryPayload,
18
21
  };
22
+ use crate::state::projection::OwnerTeamResolution;
19
23
 
20
24
  // ===========================================================================
21
25
  // internal_delivery.py — coordinator/调度器侧 thin wrapper (card §65)
@@ -135,6 +139,76 @@ pub fn deliver_pending_message(
135
139
  channel: None,
136
140
  });
137
141
  };
142
+ let mut canonical_owner_team_id = message.owner_team_id.clone();
143
+ let scoped_state;
144
+ let state = match message.owner_team_id.as_deref() {
145
+ Some(team) if !team.is_empty() => {
146
+ match project_state_for_owner_team(workspace, team, state, Some(store), Some(message_id), Some(event_log))? {
147
+ OwnerTeamProjection::Projected { state, canonical_team } => {
148
+ canonical_owner_team_id = Some(canonical_team);
149
+ scoped_state = state;
150
+ &scoped_state
151
+ }
152
+ OwnerTeamProjection::Refused(outcome) => return Ok(outcome),
153
+ }
154
+ }
155
+ _ => state,
156
+ };
157
+ if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
158
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
159
+ event_log.write(
160
+ "leader_receiver.delivery_blocked",
161
+ serde_json::json!({
162
+ "message_id": message_id,
163
+ "sender": message.sender,
164
+ "reason": "leader_not_attached",
165
+ "channel": "rebind_required",
166
+ "action": "run team-agent claim-leader or team-agent takeover",
167
+ "error": "leader_receiver.tmux_socket is not a canonical full socket path",
168
+ }),
169
+ )?;
170
+ return Ok(DeliveryOutcome {
171
+ ok: false,
172
+ status: DeliveryStatus::Refused,
173
+ message_status: MessageStatusShadow("failed".to_string()),
174
+ message_id: Some(message_id.to_string()),
175
+ verification: Some(
176
+ "run team-agent claim-leader or team-agent takeover".to_string(),
177
+ ),
178
+ stage: None,
179
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
180
+ channel: Some("rebind_required".to_string()),
181
+ });
182
+ }
183
+ let delivery_transport =
184
+ delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
185
+ let transport = delivery_transport.as_transport();
186
+ // Do not inject queued leader messages into a synthetic "leader" window.
187
+ if message.recipient == "leader" && !leader_receiver_pane_is_usable(transport, state) {
188
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
189
+ event_log.write(
190
+ "leader_receiver.delivery_blocked",
191
+ serde_json::json!({
192
+ "message_id": message_id,
193
+ "sender": message.sender,
194
+ "reason": "leader_not_attached",
195
+ "channel": "rebind_required",
196
+ "action": "run team-agent claim-leader or team-agent takeover",
197
+ }),
198
+ )?;
199
+ return Ok(DeliveryOutcome {
200
+ ok: false,
201
+ status: DeliveryStatus::Refused,
202
+ message_status: MessageStatusShadow("failed".to_string()),
203
+ message_id: Some(message_id.to_string()),
204
+ verification: Some(
205
+ "run team-agent claim-leader or team-agent takeover".to_string(),
206
+ ),
207
+ stage: None,
208
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
209
+ channel: Some("rebind_required".to_string()),
210
+ });
211
+ }
138
212
  let target = resolve_inject_target(state, &message.recipient);
139
213
  // Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
140
214
  // menu is NOT provider delivery — the menu consumes the Enter and the task text
@@ -170,12 +244,70 @@ pub fn deliver_pending_message(
170
244
  &message.content,
171
245
  message_id,
172
246
  );
173
- transport.inject(
247
+ let rendered_len = rendered.len();
248
+ let inject_report = match transport.inject(
174
249
  &target,
175
250
  &InjectPayload::Text(rendered),
176
251
  Key::Enter,
177
252
  true,
178
- )?;
253
+ ) {
254
+ Ok(report) => report,
255
+ Err(error) => {
256
+ if message.recipient == "leader" {
257
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
258
+ event_log.write(
259
+ "leader_receiver.delivery_blocked",
260
+ serde_json::json!({
261
+ "message_id": message_id,
262
+ "sender": message.sender,
263
+ "reason": "leader_not_attached",
264
+ "channel": "rebind_required",
265
+ "action": "run team-agent claim-leader or team-agent takeover",
266
+ "error": error.to_string(),
267
+ }),
268
+ )?;
269
+ return Ok(DeliveryOutcome {
270
+ ok: false,
271
+ status: DeliveryStatus::Refused,
272
+ message_status: MessageStatusShadow("failed".to_string()),
273
+ message_id: Some(message_id.to_string()),
274
+ verification: Some(
275
+ "run team-agent claim-leader or team-agent takeover".to_string(),
276
+ ),
277
+ stage: None,
278
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
279
+ channel: Some("rebind_required".to_string()),
280
+ });
281
+ }
282
+ return Err(error.into());
283
+ }
284
+ };
285
+ if !inject_submit_verified(&inject_report, rendered_len, &message.sender, &message.recipient) {
286
+ let reason = format!(
287
+ "submit_unverified:{}",
288
+ submit_verification_wire(inject_report.submit_verification)
289
+ );
290
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
291
+ event_log.write(
292
+ "send.unverified",
293
+ serde_json::json!({
294
+ "message_id": message_id,
295
+ "recipient": message.recipient,
296
+ "reason": reason,
297
+ "attempts": inject_report.attempts,
298
+ }),
299
+ )?;
300
+ return Ok(DeliveryOutcome {
301
+ ok: false,
302
+ status: DeliveryStatus::Failed,
303
+ message_status: MessageStatusShadow("submitted_unverified".to_string()),
304
+ message_id: Some(message_id.to_string()),
305
+ verification: Some(reason),
306
+ stage: Some(DeliveryStage::Submit),
307
+ reason: None,
308
+ channel: None,
309
+ });
310
+ }
179
311
  store.mark(message_id, "delivered", None)?;
180
312
  event_log.write(
181
313
  "message.delivered",
@@ -191,18 +323,39 @@ pub fn deliver_pending_message(
191
323
  reason: None,
192
324
  channel: None,
193
325
  };
194
- stamp_first_send_at_if_leader_to_worker(workspace, state, &message.sender, &message.recipient)?;
195
- record_turn_open_if_leader_to_worker(
326
+ stamp_first_send_at_if_leader_to_worker_scoped(
327
+ workspace,
328
+ &message.sender,
329
+ &message.recipient,
330
+ canonical_owner_team_id.as_deref(),
331
+ )?;
332
+ record_turn_open_if_leader_to_worker_scoped(
196
333
  workspace,
197
- state,
198
334
  &message.sender,
199
335
  &message.recipient,
200
336
  &outcome,
201
337
  event_log,
338
+ canonical_owner_team_id.as_deref(),
202
339
  )?;
203
340
  Ok(outcome)
204
341
  }
205
342
 
343
+ fn inject_submit_verified(
344
+ report: &InjectReport,
345
+ payload_len: usize,
346
+ sender: &str,
347
+ recipient: &str,
348
+ ) -> bool {
349
+ match report.submit_verification {
350
+ SubmitVerification::SendKeysFailed => false,
351
+ SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
352
+ SubmitVerification::KeySentAfterVisibleToken { .. } => true,
353
+ SubmitVerification::EnterSentWithoutPlaceholderCheck => {
354
+ recipient == "leader" || matches!(sender, "leader" | "Leader") || payload_len < 80
355
+ }
356
+ }
357
+ }
358
+
206
359
  /// Render a message into the worker-facing protocol block (port of `rust_core.py:render_message`,
207
360
  /// golden-verified): `Team Agent message from {sender}[ for {task_id}]:\n\n{content}\n\n
208
361
  /// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
@@ -220,7 +373,15 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
220
373
  /// else a session-qualified `SessionWindow` (state.session_name + the agent's window, defaulting to the
221
374
  /// id). NEVER the bare agent-id as a pane — a clientless coordinator cannot resolve that
222
375
  /// ("can't find pane: w1", rt-host-a loop #3). Mirrors `coordinator/tick.rs::capture_target`.
376
+ ///
377
+ /// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
378
+ /// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
223
379
  fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
380
+ if recipient == "leader" {
381
+ if let Some(pane_id) = leader_receiver_pane_id(state) {
382
+ return Target::Pane(PaneId::new(pane_id));
383
+ }
384
+ }
224
385
  let agent = state.get("agents").and_then(|a| a.get(recipient));
225
386
  if let Some(pane_id) = agent
226
387
  .and_then(|a| a.get("pane_id"))
@@ -244,6 +405,116 @@ fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
244
405
  }
245
406
  }
246
407
 
408
+ /// Read the bound leader pane id off the projected or team-scoped runtime state.
409
+ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
410
+ leader_receiver_pane_id_in_state(state)
411
+ .or_else(|| active_team_entry(state).and_then(leader_receiver_pane_id_in_state))
412
+ .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
413
+ }
414
+
415
+ fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json::Value) -> bool {
416
+ let Some(pane_id) = leader_receiver_pane_id(state) else {
417
+ return false;
418
+ };
419
+ if transport
420
+ .list_targets()
421
+ .unwrap_or_default()
422
+ .iter()
423
+ .any(|target| target.pane_id.as_str() == pane_id)
424
+ {
425
+ return true;
426
+ }
427
+ !matches!(transport.liveness(&PaneId::new(pane_id)), Ok(PaneLiveness::Dead))
428
+ }
429
+
430
+ enum DeliveryTransport<'a> {
431
+ Borrowed(&'a dyn Transport),
432
+ Owned(crate::tmux_backend::TmuxBackend),
433
+ }
434
+
435
+ impl<'a> DeliveryTransport<'a> {
436
+ fn as_transport(&'a self) -> &'a dyn Transport {
437
+ match self {
438
+ Self::Borrowed(transport) => *transport,
439
+ Self::Owned(transport) => transport,
440
+ }
441
+ }
442
+ }
443
+
444
+ fn delivery_transport_for_recipient<'a>(
445
+ workspace: &Path,
446
+ product_transport: &'a dyn Transport,
447
+ state: &serde_json::Value,
448
+ recipient: &str,
449
+ ) -> DeliveryTransport<'a> {
450
+ if recipient != "leader" {
451
+ return DeliveryTransport::Borrowed(product_transport);
452
+ }
453
+ let Some(socket) = leader_receiver_tmux_socket(state) else {
454
+ return DeliveryTransport::Borrowed(product_transport);
455
+ };
456
+ if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
457
+ DeliveryTransport::Borrowed(product_transport)
458
+ } else {
459
+ DeliveryTransport::Owned(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket))
460
+ }
461
+ }
462
+
463
+ fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
464
+ ["leader_receiver", "team_owner"].into_iter().find_map(|key| {
465
+ state
466
+ .get(key)
467
+ .and_then(|r| r.get("pane_id"))
468
+ .and_then(serde_json::Value::as_str)
469
+ .filter(|s| !s.is_empty())
470
+ })
471
+ }
472
+
473
+ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
474
+ leader_receiver_field(state, "tmux_socket")
475
+ }
476
+
477
+ fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
478
+ leader_receiver_tmux_socket(state)
479
+ .is_some_and(|socket| {
480
+ socket != "default" && !std::path::Path::new(socket).is_absolute()
481
+ })
482
+ }
483
+
484
+ fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
485
+ leader_receiver_field_in_state(state, field)
486
+ .or_else(|| active_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
487
+ .or_else(|| only_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
488
+ }
489
+
490
+ fn leader_receiver_field_in_state<'a>(
491
+ state: &'a serde_json::Value,
492
+ field: &str,
493
+ ) -> Option<&'a str> {
494
+ state
495
+ .get("leader_receiver")
496
+ .and_then(|receiver| receiver.get(field))
497
+ .and_then(serde_json::Value::as_str)
498
+ .filter(|value| !value.is_empty())
499
+ }
500
+
501
+ fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
502
+ let team = state.get("active_team_key").and_then(serde_json::Value::as_str)?;
503
+ state
504
+ .get("teams")
505
+ .and_then(serde_json::Value::as_object)
506
+ .and_then(|teams| teams.get(team))
507
+ }
508
+
509
+ fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
510
+ let teams = state.get("teams").and_then(serde_json::Value::as_object)?;
511
+ if teams.len() == 1 {
512
+ teams.values().next()
513
+ } else {
514
+ None
515
+ }
516
+ }
517
+
247
518
  /// `_deliver_pending_messages` (`delivery.py:484`):扫 pending 队列逐条投递;busy 收件人写
248
519
  /// `send.deferred_busy` 跳过 (**不丢**,card §131)。返回投递的 message_id 列表。
249
520
  pub fn deliver_pending_messages(
@@ -266,6 +537,19 @@ pub fn deliver_pending_messages(
266
537
  let mut delivered = Vec::new();
267
538
  for message_id in message_ids {
268
539
  if let Some(message) = message_for_delivery(&store, &message_id)? {
540
+ let scoped_state;
541
+ let state = match message.owner_team_id.as_deref() {
542
+ Some(team) if !team.is_empty() => {
543
+ match project_state_for_owner_team(workspace, team, state, Some(&store), Some(&message_id), Some(event_log))? {
544
+ OwnerTeamProjection::Projected { state, .. } => {
545
+ scoped_state = state;
546
+ &scoped_state
547
+ }
548
+ OwnerTeamProjection::Refused(_) => continue,
549
+ }
550
+ }
551
+ _ => state,
552
+ };
269
553
  if recipient_is_busy(state, &message.recipient) {
270
554
  event_log.write(
271
555
  "send.deferred_busy",
@@ -292,6 +576,7 @@ struct PendingMessage {
292
576
  recipient: String,
293
577
  content: String,
294
578
  task_id: Option<String>,
579
+ owner_team_id: Option<String>,
295
580
  }
296
581
 
297
582
  fn message_for_delivery(
@@ -301,7 +586,7 @@ fn message_for_delivery(
301
586
  let conn = crate::db::schema::open_db(store.db_path())?;
302
587
  let message = conn
303
588
  .query_row(
304
- "select sender, recipient, content, task_id from messages where message_id = ?1",
589
+ "select sender, recipient, content, task_id, owner_team_id from messages where message_id = ?1",
305
590
  params![message_id],
306
591
  |row| {
307
592
  Ok(PendingMessage {
@@ -309,6 +594,7 @@ fn message_for_delivery(
309
594
  recipient: row.get::<_, String>(1)?,
310
595
  content: row.get::<_, String>(2)?,
311
596
  task_id: row.get::<_, Option<String>>(3)?,
597
+ owner_team_id: row.get::<_, Option<String>>(4)?,
312
598
  })
313
599
  },
314
600
  )
@@ -465,10 +751,28 @@ pub fn record_turn_open_if_leader_to_worker(
465
751
  event_log: &EventLog,
466
752
  ) -> Result<(), MessagingError> {
467
753
  let _ = state;
754
+ record_turn_open_if_leader_to_worker_scoped(
755
+ workspace,
756
+ sender,
757
+ recipient,
758
+ delivered,
759
+ event_log,
760
+ None,
761
+ )
762
+ }
763
+
764
+ fn record_turn_open_if_leader_to_worker_scoped(
765
+ workspace: &Path,
766
+ sender: &str,
767
+ recipient: &str,
768
+ delivered: &DeliveryOutcome,
769
+ event_log: &EventLog,
770
+ owner_team_id: Option<&str>,
771
+ ) -> Result<(), MessagingError> {
468
772
  if !delivered.ok || !matches!(sender, "leader" | "Leader") || recipient == "leader" {
469
773
  return Ok(());
470
774
  }
471
- let mut state = crate::state::persist::load_runtime_state(workspace)?;
775
+ let mut state = scoped_state_for_write(workspace, owner_team_id)?;
472
776
  let Some(root) = state.as_object_mut() else {
473
777
  return Ok(());
474
778
  };
@@ -481,7 +785,7 @@ pub fn record_turn_open_if_leader_to_worker(
481
785
  serde_json::json!({"armed": true, "node_id": recipient, "turn_id": delivered.message_id}),
482
786
  );
483
787
  }
484
- crate::state::persist::save_runtime_state(workspace, &state)?;
788
+ save_scoped_state(workspace, &state, owner_team_id)?;
485
789
  event_log.write(
486
790
  "turn_open.armed_after_delivery",
487
791
  serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id}),
@@ -498,10 +802,19 @@ pub fn stamp_first_send_at_if_leader_to_worker(
498
802
  recipient: &str,
499
803
  ) -> Result<(), MessagingError> {
500
804
  let _ = state;
805
+ stamp_first_send_at_if_leader_to_worker_scoped(workspace, sender, recipient, None)
806
+ }
807
+
808
+ fn stamp_first_send_at_if_leader_to_worker_scoped(
809
+ workspace: &Path,
810
+ sender: &str,
811
+ recipient: &str,
812
+ owner_team_id: Option<&str>,
813
+ ) -> Result<(), MessagingError> {
501
814
  if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
502
815
  return Ok(());
503
816
  }
504
- let mut state = crate::state::persist::load_runtime_state(workspace)?;
817
+ let mut state = scoped_state_for_write(workspace, owner_team_id)?;
505
818
  let now = chrono::Utc::now().to_rfc3339();
506
819
  if let Some(agent) = state
507
820
  .get_mut("agents")
@@ -511,12 +824,261 @@ pub fn stamp_first_send_at_if_leader_to_worker(
511
824
  {
512
825
  if !agent.contains_key("first_send_at") || agent.get("first_send_at").is_some_and(serde_json::Value::is_null) {
513
826
  agent.insert("first_send_at".to_string(), serde_json::Value::String(now));
514
- crate::state::persist::save_runtime_state(workspace, &state)?;
827
+ save_scoped_state(workspace, &state, owner_team_id)?;
828
+ }
829
+ }
830
+ Ok(())
831
+ }
832
+
833
+ fn scoped_state_for_write(
834
+ workspace: &Path,
835
+ owner_team_id: Option<&str>,
836
+ ) -> Result<serde_json::Value, MessagingError> {
837
+ match owner_team_id.filter(|team| !team.is_empty()) {
838
+ Some(team) => {
839
+ let raw = crate::state::persist::load_runtime_state(workspace)?;
840
+ match project_state_for_owner_team_value(&raw, team) {
841
+ Some(projected) => Ok(projected),
842
+ None => Ok(raw),
843
+ }
844
+ }
845
+ None => Ok(crate::state::persist::load_runtime_state(workspace)?),
846
+ }
847
+ }
848
+
849
+ fn save_scoped_state(
850
+ workspace: &Path,
851
+ state: &serde_json::Value,
852
+ owner_team_id: Option<&str>,
853
+ ) -> Result<(), MessagingError> {
854
+ if owner_team_id.filter(|team| !team.is_empty()).is_some() {
855
+ if state
856
+ .get("teams")
857
+ .and_then(serde_json::Value::as_object)
858
+ .is_some_and(|teams| {
859
+ owner_team_id
860
+ .and_then(|team| crate::state::projection::resolve_owner_team_id(state, team).canonical_key().map(str::to_string))
861
+ .is_some_and(|team| teams.contains_key(&team))
862
+ })
863
+ {
864
+ crate::state::projection::save_team_scoped_state(workspace, state)?;
865
+ } else {
866
+ crate::state::persist::save_runtime_state(workspace, state)?;
867
+ }
868
+ } else {
869
+ crate::state::persist::save_runtime_state(workspace, state)?;
870
+ }
871
+ Ok(())
872
+ }
873
+
874
+ enum OwnerTeamProjection {
875
+ Projected { state: serde_json::Value, canonical_team: String },
876
+ Refused(DeliveryOutcome),
877
+ }
878
+
879
+ fn project_state_for_owner_team(
880
+ workspace: &Path,
881
+ team: &str,
882
+ fallback: &serde_json::Value,
883
+ store: Option<&MessageStore>,
884
+ message_id: Option<&str>,
885
+ event_log: Option<&EventLog>,
886
+ ) -> Result<OwnerTeamProjection, MessagingError> {
887
+ let raw = crate::state::persist::load_runtime_state(workspace)?;
888
+ let fallback_has_teams = fallback
889
+ .get("teams")
890
+ .and_then(serde_json::Value::as_object)
891
+ .is_some_and(|teams| !teams.is_empty());
892
+ let (mut projection_source, mut resolution) = if fallback_has_teams {
893
+ (fallback, crate::state::projection::resolve_owner_team_id(fallback, team))
894
+ } else {
895
+ (&raw, crate::state::projection::resolve_owner_team_id(&raw, team))
896
+ };
897
+ if !fallback_has_teams && matches!(resolution, OwnerTeamResolution::Unresolved { .. }) {
898
+ let fallback_resolution = crate::state::projection::resolve_owner_team_id(fallback, team);
899
+ if !matches!(fallback_resolution, OwnerTeamResolution::Unresolved { .. }) {
900
+ resolution = fallback_resolution;
901
+ projection_source = fallback;
902
+ }
903
+ }
904
+ let canonical_team = match resolution {
905
+ OwnerTeamResolution::Canonical(canonical) => canonical,
906
+ OwnerTeamResolution::LegacyAlias { requested, canonical } => {
907
+ normalize_owner_team_id_rows(workspace, &requested, &canonical, message_id, event_log)?;
908
+ canonical
909
+ }
910
+ OwnerTeamResolution::Unresolved { requested } => {
911
+ let outcome = refuse_owner_team_resolution(
912
+ store,
913
+ message_id,
914
+ event_log,
915
+ "owner_team_unresolved",
916
+ serde_json::json!({"owner_team_id": requested}),
917
+ DeliveryRefusal::UnknownRecipient,
918
+ )?;
919
+ return Ok(OwnerTeamProjection::Refused(outcome));
920
+ }
921
+ OwnerTeamResolution::Ambiguous { requested, matches } => {
922
+ let outcome = refuse_owner_team_resolution(
923
+ store,
924
+ message_id,
925
+ event_log,
926
+ "owner_team_ambiguous",
927
+ serde_json::json!({"owner_team_id": requested, "matches": matches}),
928
+ DeliveryRefusal::Ambiguous,
929
+ )?;
930
+ return Ok(OwnerTeamProjection::Refused(outcome));
931
+ }
932
+ };
933
+ if top_level_state_matches_owner_team(fallback, &canonical_team) {
934
+ let mut state = fallback.clone();
935
+ carry_top_level_leader_binding(&mut state, &raw);
936
+ return Ok(OwnerTeamProjection::Projected {
937
+ state,
938
+ canonical_team,
939
+ });
940
+ }
941
+ if top_level_state_matches_owner_team(&raw, &canonical_team) {
942
+ return Ok(OwnerTeamProjection::Projected {
943
+ state: raw,
944
+ canonical_team,
945
+ });
946
+ }
947
+ if state_has_no_team_entries(projection_source) {
948
+ let mut state = projection_source.clone();
949
+ carry_top_level_leader_binding(&mut state, &raw);
950
+ return Ok(OwnerTeamProjection::Projected {
951
+ state,
952
+ canonical_team,
953
+ });
954
+ }
955
+ let mut state = project_state_for_owner_team_value(projection_source, &canonical_team)
956
+ .ok_or_else(|| MessagingError::Routing(format!("owner_team_unresolved: {canonical_team}")))?;
957
+ carry_top_level_leader_binding(&mut state, projection_source);
958
+ carry_top_level_leader_binding(&mut state, &raw);
959
+ Ok(OwnerTeamProjection::Projected { state, canonical_team })
960
+ }
961
+
962
+ fn carry_top_level_leader_binding(projected: &mut serde_json::Value, raw: &serde_json::Value) {
963
+ let Some(projected_obj) = projected.as_object_mut() else {
964
+ return;
965
+ };
966
+ for key in ["leader_receiver", "team_owner", "owner_epoch"] {
967
+ if projected_obj.contains_key(key) {
968
+ continue;
969
+ }
970
+ if let Some(value) = raw.get(key) {
971
+ projected_obj.insert(key.to_string(), value.clone());
515
972
  }
516
973
  }
974
+ }
975
+
976
+ fn state_has_no_team_entries(state: &serde_json::Value) -> bool {
977
+ state
978
+ .get("teams")
979
+ .and_then(serde_json::Value::as_object)
980
+ .is_none_or(serde_json::Map::is_empty)
981
+ }
982
+
983
+ pub(crate) fn normalize_owner_team_id_rows(
984
+ workspace: &Path,
985
+ requested: &str,
986
+ canonical: &str,
987
+ message_id: Option<&str>,
988
+ event_log: Option<&EventLog>,
989
+ ) -> Result<(), MessagingError> {
990
+ if requested == canonical {
991
+ return Ok(());
992
+ }
993
+ let store = MessageStore::open(workspace)?;
994
+ let conn = crate::db::schema::open_db(store.db_path())?;
995
+ for table in [
996
+ "messages",
997
+ "results",
998
+ "scheduled_events",
999
+ "agent_health",
1000
+ "result_watchers",
1001
+ "leader_notification_log",
1002
+ ] {
1003
+ let sql = format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
1004
+ conn.execute(&sql, params![canonical, requested])?;
1005
+ }
1006
+ if let Some(event_log) = event_log {
1007
+ event_log.write(
1008
+ "owner_team_id.compatibility_alias_migrated",
1009
+ serde_json::json!({
1010
+ "requested_owner_team_id": requested,
1011
+ "canonical_owner_team_id": canonical,
1012
+ "message_id": message_id,
1013
+ }),
1014
+ )?;
1015
+ }
517
1016
  Ok(())
518
1017
  }
519
1018
 
1019
+ fn refuse_owner_team_resolution(
1020
+ store: Option<&MessageStore>,
1021
+ message_id: Option<&str>,
1022
+ event_log: Option<&EventLog>,
1023
+ error: &str,
1024
+ details: serde_json::Value,
1025
+ refusal: DeliveryRefusal,
1026
+ ) -> Result<DeliveryOutcome, MessagingError> {
1027
+ if let (Some(store), Some(message_id)) = (store, message_id) {
1028
+ store.mark(message_id, "failed", Some(error))?;
1029
+ }
1030
+ if let Some(event_log) = event_log {
1031
+ event_log.write(
1032
+ "owner_team_id.resolution_failed",
1033
+ serde_json::json!({
1034
+ "message_id": message_id,
1035
+ "error": error,
1036
+ "details": details,
1037
+ }),
1038
+ )?;
1039
+ }
1040
+ Ok(DeliveryOutcome {
1041
+ ok: false,
1042
+ status: DeliveryStatus::Refused,
1043
+ message_status: MessageStatusShadow("failed".to_string()),
1044
+ message_id: message_id.map(str::to_string),
1045
+ verification: Some(error.to_string()),
1046
+ stage: None,
1047
+ reason: Some(refusal),
1048
+ channel: Some("owner_team_resolution".to_string()),
1049
+ })
1050
+ }
1051
+
1052
+ fn project_state_for_owner_team_value(
1053
+ raw: &serde_json::Value,
1054
+ team: &str,
1055
+ ) -> Option<serde_json::Value> {
1056
+ if let Some(projected) = raw
1057
+ .get("teams")
1058
+ .and_then(serde_json::Value::as_object)
1059
+ .is_some_and(|teams| teams.contains_key(team))
1060
+ .then(|| crate::state::projection::project_top_level_view(raw, team))
1061
+ {
1062
+ return Some(projected);
1063
+ }
1064
+ if top_level_state_matches_owner_team(raw, team) {
1065
+ return None;
1066
+ }
1067
+ None
1068
+ }
1069
+
1070
+ fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
1071
+ state
1072
+ .get("active_team_key")
1073
+ .and_then(serde_json::Value::as_str)
1074
+ .is_some_and(|value| value == team)
1075
+ || crate::state::projection::team_state_key(state) == team
1076
+ || state
1077
+ .get("session_name")
1078
+ .and_then(serde_json::Value::as_str)
1079
+ .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
1080
+ }
1081
+
520
1082
  /// `retry_injection_after_trust_auto_answer` (`trust_auto_answer.py`):leader 路径 trust 应答
521
1083
  /// 后重注入 (查 pane_width fail-safe + attempt_trust_auto_answer + 等 dismissal + 重 inject)。
522
1084
  pub fn retry_injection_after_trust_auto_answer(