@team-agent/installer 0.3.1 → 0.3.3

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 (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -9,13 +9,17 @@ use crate::event_log::EventLog;
9
9
  use crate::message_store::MessageStore;
10
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)
@@ -111,30 +115,45 @@ pub fn deliver_pending_message(
111
115
  });
112
116
  }
113
117
  let message = message_for_delivery(store, message_id)?;
114
- if !store.claim_for_delivery(message_id)? {
118
+ let Some(message) = message else {
115
119
  return Ok(DeliveryOutcome {
116
120
  ok: false,
117
- status: DeliveryStatus::Refused,
118
- message_status: MessageStatusShadow("target_resolved".to_string()),
121
+ status: DeliveryStatus::Failed,
122
+ message_status: MessageStatusShadow("failed".to_string()),
119
123
  message_id: Some(message_id.to_string()),
120
124
  verification: None,
121
125
  stage: None,
122
- reason: Some(DeliveryRefusal::MessageAlreadyClaimed),
126
+ reason: Some(DeliveryRefusal::UnknownRecipient),
123
127
  channel: None,
124
128
  });
125
- }
126
- let Some(message) = message else {
129
+ };
130
+ let mut canonical_owner_team_id = message.owner_team_id.clone();
131
+ let scoped_state;
132
+ let state = match message.owner_team_id.as_deref() {
133
+ Some(team) if !team.is_empty() => {
134
+ match project_state_for_owner_team(workspace, team, state, Some(store), Some(message_id), Some(event_log))? {
135
+ OwnerTeamProjection::Projected { state, canonical_team } => {
136
+ canonical_owner_team_id = Some(canonical_team);
137
+ scoped_state = state;
138
+ &scoped_state
139
+ }
140
+ OwnerTeamProjection::Refused(outcome) => return Ok(outcome),
141
+ }
142
+ }
143
+ _ => state,
144
+ };
145
+ if !store.claim_for_delivery(message_id)? && message.status != "target_resolved" {
127
146
  return Ok(DeliveryOutcome {
128
147
  ok: false,
129
- status: DeliveryStatus::Failed,
130
- message_status: MessageStatusShadow("failed".to_string()),
148
+ status: DeliveryStatus::Refused,
149
+ message_status: MessageStatusShadow("target_resolved".to_string()),
131
150
  message_id: Some(message_id.to_string()),
132
151
  verification: None,
133
152
  stage: None,
134
- reason: Some(DeliveryRefusal::UnknownRecipient),
153
+ reason: Some(DeliveryRefusal::MessageAlreadyClaimed),
135
154
  channel: None,
136
155
  });
137
- };
156
+ }
138
157
  if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
139
158
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
140
159
  event_log.write(
@@ -225,39 +244,68 @@ pub fn deliver_pending_message(
225
244
  &message.content,
226
245
  message_id,
227
246
  );
228
- if let Err(error) = transport.inject(
247
+ let inject_report = match transport.inject(
229
248
  &target,
230
249
  &InjectPayload::Text(rendered),
231
250
  Key::Enter,
232
251
  true,
233
252
  ) {
234
- if message.recipient == "leader" {
235
- store.mark(message_id, "failed", Some("leader_not_attached"))?;
236
- event_log.write(
237
- "leader_receiver.delivery_blocked",
238
- serde_json::json!({
239
- "message_id": message_id,
240
- "sender": message.sender,
241
- "reason": "leader_not_attached",
242
- "channel": "rebind_required",
243
- "action": "run team-agent claim-leader or team-agent takeover",
244
- "error": error.to_string(),
245
- }),
246
- )?;
247
- return Ok(DeliveryOutcome {
248
- ok: false,
249
- status: DeliveryStatus::Refused,
250
- message_status: MessageStatusShadow("failed".to_string()),
251
- message_id: Some(message_id.to_string()),
252
- verification: Some(
253
- "run team-agent claim-leader or team-agent takeover".to_string(),
254
- ),
255
- stage: None,
256
- reason: Some(DeliveryRefusal::LeaderNotAttached),
257
- channel: Some("rebind_required".to_string()),
258
- });
253
+ Ok(report) => report,
254
+ Err(error) => {
255
+ if message.recipient == "leader" {
256
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
257
+ event_log.write(
258
+ "leader_receiver.delivery_blocked",
259
+ serde_json::json!({
260
+ "message_id": message_id,
261
+ "sender": message.sender,
262
+ "reason": "leader_not_attached",
263
+ "channel": "rebind_required",
264
+ "action": "run team-agent claim-leader or team-agent takeover",
265
+ "error": error.to_string(),
266
+ }),
267
+ )?;
268
+ return Ok(DeliveryOutcome {
269
+ ok: false,
270
+ status: DeliveryStatus::Refused,
271
+ message_status: MessageStatusShadow("failed".to_string()),
272
+ message_id: Some(message_id.to_string()),
273
+ verification: Some(
274
+ "run team-agent claim-leader or team-agent takeover".to_string(),
275
+ ),
276
+ stage: None,
277
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
278
+ channel: Some("rebind_required".to_string()),
279
+ });
280
+ }
281
+ return Err(error.into());
259
282
  }
260
- return Err(error.into());
283
+ };
284
+ if !inject_submit_verified(&inject_report) {
285
+ let reason = format!(
286
+ "submit_unverified:{}",
287
+ submit_verification_wire(inject_report.submit_verification)
288
+ );
289
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
290
+ event_log.write(
291
+ "send.unverified",
292
+ serde_json::json!({
293
+ "message_id": message_id,
294
+ "recipient": message.recipient,
295
+ "reason": reason,
296
+ "attempts": inject_report.attempts,
297
+ }),
298
+ )?;
299
+ return Ok(DeliveryOutcome {
300
+ ok: false,
301
+ status: DeliveryStatus::Failed,
302
+ message_status: MessageStatusShadow("submitted_unverified".to_string()),
303
+ message_id: Some(message_id.to_string()),
304
+ verification: Some(reason),
305
+ stage: Some(DeliveryStage::Submit),
306
+ reason: None,
307
+ channel: None,
308
+ });
261
309
  }
262
310
  store.mark(message_id, "delivered", None)?;
263
311
  event_log.write(
@@ -274,18 +322,33 @@ pub fn deliver_pending_message(
274
322
  reason: None,
275
323
  channel: None,
276
324
  };
277
- stamp_first_send_at_if_leader_to_worker(workspace, state, &message.sender, &message.recipient)?;
278
- record_turn_open_if_leader_to_worker(
325
+ stamp_first_send_at_if_leader_to_worker_scoped(
326
+ workspace,
327
+ &message.sender,
328
+ &message.recipient,
329
+ canonical_owner_team_id.as_deref(),
330
+ )?;
331
+ record_turn_open_if_leader_to_worker_scoped(
279
332
  workspace,
280
- state,
281
333
  &message.sender,
282
334
  &message.recipient,
283
335
  &outcome,
284
336
  event_log,
337
+ canonical_owner_team_id.as_deref(),
285
338
  )?;
286
339
  Ok(outcome)
287
340
  }
288
341
 
342
+ fn inject_submit_verified(report: &InjectReport) -> bool {
343
+ match report.submit_verification {
344
+ SubmitVerification::SendKeysFailed => false,
345
+ SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
346
+ SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
347
+ SubmitVerification::KeySentAfterVisibleToken { .. } => true,
348
+ SubmitVerification::EnterSentWithoutPlaceholderCheck => true,
349
+ }
350
+ }
351
+
289
352
  /// Render a message into the worker-facing protocol block (port of `rust_core.py:render_message`,
290
353
  /// golden-verified): `Team Agent message from {sender}[ for {task_id}]:\n\n{content}\n\n
291
354
  /// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
@@ -380,13 +443,60 @@ fn delivery_transport_for_recipient<'a>(
380
443
  if recipient != "leader" {
381
444
  return DeliveryTransport::Borrowed(product_transport);
382
445
  }
446
+ let pane_id = leader_receiver_pane_id(state);
383
447
  let Some(socket) = leader_receiver_tmux_socket(state) else {
448
+ if let Some(pane_id) = pane_id {
449
+ let in_workspace = product_transport
450
+ .list_targets()
451
+ .unwrap_or_default()
452
+ .iter()
453
+ .any(|target| target.pane_id.as_str() == pane_id);
454
+ if !in_workspace {
455
+ let default_backend = crate::tmux_backend::TmuxBackend::new();
456
+ if default_backend
457
+ .list_targets()
458
+ .unwrap_or_default()
459
+ .iter()
460
+ .any(|target| target.pane_id.as_str() == pane_id)
461
+ {
462
+ return DeliveryTransport::Owned(default_backend);
463
+ }
464
+ }
465
+ }
384
466
  return DeliveryTransport::Borrowed(product_transport);
385
467
  };
386
468
  if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
387
469
  DeliveryTransport::Borrowed(product_transport)
388
470
  } else {
389
- DeliveryTransport::Owned(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket))
471
+ let endpoint_backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
472
+ if let Some(pane_id) = pane_id {
473
+ if endpoint_backend
474
+ .list_targets()
475
+ .unwrap_or_default()
476
+ .iter()
477
+ .any(|target| target.pane_id.as_str() == pane_id)
478
+ {
479
+ return DeliveryTransport::Owned(endpoint_backend);
480
+ }
481
+ if product_transport
482
+ .list_targets()
483
+ .unwrap_or_default()
484
+ .iter()
485
+ .any(|target| target.pane_id.as_str() == pane_id)
486
+ {
487
+ return DeliveryTransport::Borrowed(product_transport);
488
+ }
489
+ let default_backend = crate::tmux_backend::TmuxBackend::new();
490
+ if default_backend
491
+ .list_targets()
492
+ .unwrap_or_default()
493
+ .iter()
494
+ .any(|target| target.pane_id.as_str() == pane_id)
495
+ {
496
+ return DeliveryTransport::Owned(default_backend);
497
+ }
498
+ }
499
+ DeliveryTransport::Owned(endpoint_backend)
390
500
  }
391
501
  }
392
502
 
@@ -396,7 +506,7 @@ fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
396
506
  .get(key)
397
507
  .and_then(|r| r.get("pane_id"))
398
508
  .and_then(serde_json::Value::as_str)
399
- .filter(|s| !s.is_empty())
509
+ .filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")
400
510
  })
401
511
  }
402
512
 
@@ -458,7 +568,7 @@ pub fn deliver_pending_messages(
458
568
  let conn = crate::db::schema::open_db(store.db_path())?;
459
569
  let mut stmt = conn.prepare(
460
570
  "select message_id from messages
461
- where status in ('pending', 'accepted')
571
+ where status in ('pending', 'accepted', 'target_resolved')
462
572
  order by created_at, message_id",
463
573
  )?;
464
574
  let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
@@ -467,6 +577,19 @@ pub fn deliver_pending_messages(
467
577
  let mut delivered = Vec::new();
468
578
  for message_id in message_ids {
469
579
  if let Some(message) = message_for_delivery(&store, &message_id)? {
580
+ let scoped_state;
581
+ let state = match message.owner_team_id.as_deref() {
582
+ Some(team) if !team.is_empty() => {
583
+ match project_state_for_owner_team(workspace, team, state, Some(&store), Some(&message_id), Some(event_log))? {
584
+ OwnerTeamProjection::Projected { state, .. } => {
585
+ scoped_state = state;
586
+ &scoped_state
587
+ }
588
+ OwnerTeamProjection::Refused(_) => continue,
589
+ }
590
+ }
591
+ _ => state,
592
+ };
470
593
  if recipient_is_busy(state, &message.recipient) {
471
594
  event_log.write(
472
595
  "send.deferred_busy",
@@ -493,6 +616,8 @@ struct PendingMessage {
493
616
  recipient: String,
494
617
  content: String,
495
618
  task_id: Option<String>,
619
+ owner_team_id: Option<String>,
620
+ status: String,
496
621
  }
497
622
 
498
623
  fn message_for_delivery(
@@ -502,7 +627,7 @@ fn message_for_delivery(
502
627
  let conn = crate::db::schema::open_db(store.db_path())?;
503
628
  let message = conn
504
629
  .query_row(
505
- "select sender, recipient, content, task_id from messages where message_id = ?1",
630
+ "select sender, recipient, content, task_id, owner_team_id, status from messages where message_id = ?1",
506
631
  params![message_id],
507
632
  |row| {
508
633
  Ok(PendingMessage {
@@ -510,6 +635,8 @@ fn message_for_delivery(
510
635
  recipient: row.get::<_, String>(1)?,
511
636
  content: row.get::<_, String>(2)?,
512
637
  task_id: row.get::<_, Option<String>>(3)?,
638
+ owner_team_id: row.get::<_, Option<String>>(4)?,
639
+ status: row.get::<_, String>(5)?,
513
640
  })
514
641
  },
515
642
  )
@@ -518,10 +645,11 @@ fn message_for_delivery(
518
645
  }
519
646
 
520
647
  /// Pre-inject gate (Contract B): peek the recipient pane and answer "is there an
521
- /// actionable Codex startup prompt right now (trust menu or update prompt)" using
522
- /// the SHARED provider/startup_prompt recognizer — no second classifier, no provider
523
- /// API calls. Returns `false` if capture fails so non-Codex providers (or any pane
524
- /// without the trust-menu shape) keep flowing through normal delivery.
648
+ /// actionable provider startup prompt right now (trust menu or update prompt)" using
649
+ /// the SHARED provider/startup_prompt recognizers — no second classifier, no provider
650
+ /// API calls. Returns `false` if capture fails so providers without a startup
651
+ /// recognizer (or any pane without the trust-menu shape) keep flowing through
652
+ /// normal delivery.
525
653
  fn recipient_pane_has_actionable_startup_prompt(
526
654
  transport: &dyn Transport,
527
655
  state: &serde_json::Value,
@@ -535,7 +663,7 @@ fn recipient_pane_has_actionable_startup_prompt(
535
663
  let provider = agent
536
664
  .and_then(|agent| agent.get("provider"))
537
665
  .and_then(serde_json::Value::as_str);
538
- if !matches!(provider, Some("codex")) {
666
+ if !matches!(provider, Some("codex" | "claude" | "claude_code")) {
539
667
  return false;
540
668
  }
541
669
  // step2-retry/scrollback root-cause (rt binary 6c9c6c1c): once the agent's
@@ -558,11 +686,18 @@ fn recipient_pane_has_actionable_startup_prompt(
558
686
  Ok(Ok(captured)) => captured.text,
559
687
  _ => return false,
560
688
  };
561
- matches!(
562
- crate::provider::classify_codex_startup_screen(&captured),
563
- crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
564
- | crate::provider::StartupScreenDecision::SkipUpdatePrompt
565
- )
689
+ match provider {
690
+ Some("codex") => matches!(
691
+ crate::provider::classify_codex_startup_screen(&captured),
692
+ crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
693
+ | crate::provider::StartupScreenDecision::SkipUpdatePrompt
694
+ ),
695
+ Some("claude" | "claude_code") => matches!(
696
+ crate::provider::classify_claude_startup_screen(&captured),
697
+ crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
698
+ ),
699
+ _ => false,
700
+ }
566
701
  }
567
702
 
568
703
  fn recipient_is_busy(state: &serde_json::Value, recipient: &str) -> bool {
@@ -666,10 +801,28 @@ pub fn record_turn_open_if_leader_to_worker(
666
801
  event_log: &EventLog,
667
802
  ) -> Result<(), MessagingError> {
668
803
  let _ = state;
804
+ record_turn_open_if_leader_to_worker_scoped(
805
+ workspace,
806
+ sender,
807
+ recipient,
808
+ delivered,
809
+ event_log,
810
+ None,
811
+ )
812
+ }
813
+
814
+ fn record_turn_open_if_leader_to_worker_scoped(
815
+ workspace: &Path,
816
+ sender: &str,
817
+ recipient: &str,
818
+ delivered: &DeliveryOutcome,
819
+ event_log: &EventLog,
820
+ owner_team_id: Option<&str>,
821
+ ) -> Result<(), MessagingError> {
669
822
  if !delivered.ok || !matches!(sender, "leader" | "Leader") || recipient == "leader" {
670
823
  return Ok(());
671
824
  }
672
- let mut state = crate::state::persist::load_runtime_state(workspace)?;
825
+ let mut state = scoped_state_for_write(workspace, owner_team_id)?;
673
826
  let Some(root) = state.as_object_mut() else {
674
827
  return Ok(());
675
828
  };
@@ -682,7 +835,7 @@ pub fn record_turn_open_if_leader_to_worker(
682
835
  serde_json::json!({"armed": true, "node_id": recipient, "turn_id": delivered.message_id}),
683
836
  );
684
837
  }
685
- crate::state::persist::save_runtime_state(workspace, &state)?;
838
+ save_scoped_state(workspace, &state, owner_team_id)?;
686
839
  event_log.write(
687
840
  "turn_open.armed_after_delivery",
688
841
  serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id}),
@@ -699,10 +852,19 @@ pub fn stamp_first_send_at_if_leader_to_worker(
699
852
  recipient: &str,
700
853
  ) -> Result<(), MessagingError> {
701
854
  let _ = state;
855
+ stamp_first_send_at_if_leader_to_worker_scoped(workspace, sender, recipient, None)
856
+ }
857
+
858
+ fn stamp_first_send_at_if_leader_to_worker_scoped(
859
+ workspace: &Path,
860
+ sender: &str,
861
+ recipient: &str,
862
+ owner_team_id: Option<&str>,
863
+ ) -> Result<(), MessagingError> {
702
864
  if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
703
865
  return Ok(());
704
866
  }
705
- let mut state = crate::state::persist::load_runtime_state(workspace)?;
867
+ let mut state = scoped_state_for_write(workspace, owner_team_id)?;
706
868
  let now = chrono::Utc::now().to_rfc3339();
707
869
  if let Some(agent) = state
708
870
  .get_mut("agents")
@@ -712,12 +874,261 @@ pub fn stamp_first_send_at_if_leader_to_worker(
712
874
  {
713
875
  if !agent.contains_key("first_send_at") || agent.get("first_send_at").is_some_and(serde_json::Value::is_null) {
714
876
  agent.insert("first_send_at".to_string(), serde_json::Value::String(now));
715
- crate::state::persist::save_runtime_state(workspace, &state)?;
877
+ save_scoped_state(workspace, &state, owner_team_id)?;
716
878
  }
717
879
  }
718
880
  Ok(())
719
881
  }
720
882
 
883
+ fn scoped_state_for_write(
884
+ workspace: &Path,
885
+ owner_team_id: Option<&str>,
886
+ ) -> Result<serde_json::Value, MessagingError> {
887
+ match owner_team_id.filter(|team| !team.is_empty()) {
888
+ Some(team) => {
889
+ let raw = crate::state::persist::load_runtime_state(workspace)?;
890
+ match project_state_for_owner_team_value(&raw, team) {
891
+ Some(projected) => Ok(projected),
892
+ None => Ok(raw),
893
+ }
894
+ }
895
+ None => Ok(crate::state::persist::load_runtime_state(workspace)?),
896
+ }
897
+ }
898
+
899
+ fn save_scoped_state(
900
+ workspace: &Path,
901
+ state: &serde_json::Value,
902
+ owner_team_id: Option<&str>,
903
+ ) -> Result<(), MessagingError> {
904
+ if owner_team_id.filter(|team| !team.is_empty()).is_some() {
905
+ if state
906
+ .get("teams")
907
+ .and_then(serde_json::Value::as_object)
908
+ .is_some_and(|teams| {
909
+ owner_team_id
910
+ .and_then(|team| crate::state::projection::resolve_owner_team_id(state, team).canonical_key().map(str::to_string))
911
+ .is_some_and(|team| teams.contains_key(&team))
912
+ })
913
+ {
914
+ crate::state::projection::save_team_scoped_state(workspace, state)?;
915
+ } else {
916
+ crate::state::persist::save_runtime_state(workspace, state)?;
917
+ }
918
+ } else {
919
+ crate::state::persist::save_runtime_state(workspace, state)?;
920
+ }
921
+ Ok(())
922
+ }
923
+
924
+ enum OwnerTeamProjection {
925
+ Projected { state: serde_json::Value, canonical_team: String },
926
+ Refused(DeliveryOutcome),
927
+ }
928
+
929
+ fn project_state_for_owner_team(
930
+ workspace: &Path,
931
+ team: &str,
932
+ fallback: &serde_json::Value,
933
+ store: Option<&MessageStore>,
934
+ message_id: Option<&str>,
935
+ event_log: Option<&EventLog>,
936
+ ) -> Result<OwnerTeamProjection, MessagingError> {
937
+ let raw = crate::state::persist::load_runtime_state(workspace)?;
938
+ let fallback_has_teams = fallback
939
+ .get("teams")
940
+ .and_then(serde_json::Value::as_object)
941
+ .is_some_and(|teams| !teams.is_empty());
942
+ let (mut projection_source, mut resolution) = if fallback_has_teams {
943
+ (fallback, crate::state::projection::resolve_owner_team_id(fallback, team))
944
+ } else {
945
+ (&raw, crate::state::projection::resolve_owner_team_id(&raw, team))
946
+ };
947
+ if !fallback_has_teams && matches!(resolution, OwnerTeamResolution::Unresolved { .. }) {
948
+ let fallback_resolution = crate::state::projection::resolve_owner_team_id(fallback, team);
949
+ if !matches!(fallback_resolution, OwnerTeamResolution::Unresolved { .. }) {
950
+ resolution = fallback_resolution;
951
+ projection_source = fallback;
952
+ }
953
+ }
954
+ let canonical_team = match resolution {
955
+ OwnerTeamResolution::Canonical(canonical) => canonical,
956
+ OwnerTeamResolution::LegacyAlias { requested, canonical } => {
957
+ normalize_owner_team_id_rows(workspace, &requested, &canonical, message_id, event_log)?;
958
+ canonical
959
+ }
960
+ OwnerTeamResolution::Unresolved { requested } => {
961
+ let outcome = refuse_owner_team_resolution(
962
+ store,
963
+ message_id,
964
+ event_log,
965
+ "owner_team_unresolved",
966
+ serde_json::json!({"owner_team_id": requested}),
967
+ DeliveryRefusal::UnknownRecipient,
968
+ )?;
969
+ return Ok(OwnerTeamProjection::Refused(outcome));
970
+ }
971
+ OwnerTeamResolution::Ambiguous { requested, matches } => {
972
+ let outcome = refuse_owner_team_resolution(
973
+ store,
974
+ message_id,
975
+ event_log,
976
+ "owner_team_ambiguous",
977
+ serde_json::json!({"owner_team_id": requested, "matches": matches}),
978
+ DeliveryRefusal::Ambiguous,
979
+ )?;
980
+ return Ok(OwnerTeamProjection::Refused(outcome));
981
+ }
982
+ };
983
+ if top_level_state_matches_owner_team(fallback, &canonical_team) {
984
+ let mut state = fallback.clone();
985
+ carry_top_level_leader_binding(&mut state, &raw);
986
+ return Ok(OwnerTeamProjection::Projected {
987
+ state,
988
+ canonical_team,
989
+ });
990
+ }
991
+ if top_level_state_matches_owner_team(&raw, &canonical_team) {
992
+ return Ok(OwnerTeamProjection::Projected {
993
+ state: raw,
994
+ canonical_team,
995
+ });
996
+ }
997
+ if state_has_no_team_entries(projection_source) {
998
+ let mut state = projection_source.clone();
999
+ carry_top_level_leader_binding(&mut state, &raw);
1000
+ return Ok(OwnerTeamProjection::Projected {
1001
+ state,
1002
+ canonical_team,
1003
+ });
1004
+ }
1005
+ let mut state = project_state_for_owner_team_value(projection_source, &canonical_team)
1006
+ .ok_or_else(|| MessagingError::Routing(format!("owner_team_unresolved: {canonical_team}")))?;
1007
+ carry_top_level_leader_binding(&mut state, projection_source);
1008
+ carry_top_level_leader_binding(&mut state, &raw);
1009
+ Ok(OwnerTeamProjection::Projected { state, canonical_team })
1010
+ }
1011
+
1012
+ fn carry_top_level_leader_binding(projected: &mut serde_json::Value, raw: &serde_json::Value) {
1013
+ let Some(projected_obj) = projected.as_object_mut() else {
1014
+ return;
1015
+ };
1016
+ for key in ["leader_receiver", "team_owner", "owner_epoch"] {
1017
+ if projected_obj.contains_key(key) {
1018
+ continue;
1019
+ }
1020
+ if let Some(value) = raw.get(key) {
1021
+ projected_obj.insert(key.to_string(), value.clone());
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ fn state_has_no_team_entries(state: &serde_json::Value) -> bool {
1027
+ state
1028
+ .get("teams")
1029
+ .and_then(serde_json::Value::as_object)
1030
+ .is_none_or(serde_json::Map::is_empty)
1031
+ }
1032
+
1033
+ pub(crate) fn normalize_owner_team_id_rows(
1034
+ workspace: &Path,
1035
+ requested: &str,
1036
+ canonical: &str,
1037
+ message_id: Option<&str>,
1038
+ event_log: Option<&EventLog>,
1039
+ ) -> Result<(), MessagingError> {
1040
+ if requested == canonical {
1041
+ return Ok(());
1042
+ }
1043
+ let store = MessageStore::open(workspace)?;
1044
+ let conn = crate::db::schema::open_db(store.db_path())?;
1045
+ for table in [
1046
+ "messages",
1047
+ "results",
1048
+ "scheduled_events",
1049
+ "agent_health",
1050
+ "result_watchers",
1051
+ "leader_notification_log",
1052
+ ] {
1053
+ let sql = format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
1054
+ conn.execute(&sql, params![canonical, requested])?;
1055
+ }
1056
+ if let Some(event_log) = event_log {
1057
+ event_log.write(
1058
+ "owner_team_id.compatibility_alias_migrated",
1059
+ serde_json::json!({
1060
+ "requested_owner_team_id": requested,
1061
+ "canonical_owner_team_id": canonical,
1062
+ "message_id": message_id,
1063
+ }),
1064
+ )?;
1065
+ }
1066
+ Ok(())
1067
+ }
1068
+
1069
+ fn refuse_owner_team_resolution(
1070
+ store: Option<&MessageStore>,
1071
+ message_id: Option<&str>,
1072
+ event_log: Option<&EventLog>,
1073
+ error: &str,
1074
+ details: serde_json::Value,
1075
+ refusal: DeliveryRefusal,
1076
+ ) -> Result<DeliveryOutcome, MessagingError> {
1077
+ if let (Some(store), Some(message_id)) = (store, message_id) {
1078
+ store.mark(message_id, "failed", Some(error))?;
1079
+ }
1080
+ if let Some(event_log) = event_log {
1081
+ event_log.write(
1082
+ "owner_team_id.resolution_failed",
1083
+ serde_json::json!({
1084
+ "message_id": message_id,
1085
+ "error": error,
1086
+ "details": details,
1087
+ }),
1088
+ )?;
1089
+ }
1090
+ Ok(DeliveryOutcome {
1091
+ ok: false,
1092
+ status: DeliveryStatus::Refused,
1093
+ message_status: MessageStatusShadow("failed".to_string()),
1094
+ message_id: message_id.map(str::to_string),
1095
+ verification: Some(error.to_string()),
1096
+ stage: None,
1097
+ reason: Some(refusal),
1098
+ channel: Some("owner_team_resolution".to_string()),
1099
+ })
1100
+ }
1101
+
1102
+ fn project_state_for_owner_team_value(
1103
+ raw: &serde_json::Value,
1104
+ team: &str,
1105
+ ) -> Option<serde_json::Value> {
1106
+ if let Some(projected) = raw
1107
+ .get("teams")
1108
+ .and_then(serde_json::Value::as_object)
1109
+ .is_some_and(|teams| teams.contains_key(team))
1110
+ .then(|| crate::state::projection::project_top_level_view(raw, team))
1111
+ {
1112
+ return Some(projected);
1113
+ }
1114
+ if top_level_state_matches_owner_team(raw, team) {
1115
+ return None;
1116
+ }
1117
+ None
1118
+ }
1119
+
1120
+ fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
1121
+ state
1122
+ .get("active_team_key")
1123
+ .and_then(serde_json::Value::as_str)
1124
+ .is_some_and(|value| value == team)
1125
+ || crate::state::projection::team_state_key(state) == team
1126
+ || state
1127
+ .get("session_name")
1128
+ .and_then(serde_json::Value::as_str)
1129
+ .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
1130
+ }
1131
+
721
1132
  /// `retry_injection_after_trust_auto_answer` (`trust_auto_answer.py`):leader 路径 trust 应答
722
1133
  /// 后重注入 (查 pane_width fail-safe + attempt_trust_auto_answer + 等 dismissal + 重 inject)。
723
1134
  pub fn retry_injection_after_trust_auto_answer(