@team-agent/installer 0.5.50 → 0.5.52

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 (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
@@ -197,27 +197,15 @@ fn collect_scoped(
197
197
  collected_results.push(summary);
198
198
  }
199
199
  if state_dirty {
200
- if owner_team_id.is_some() {
201
- crate::state::projection::save_team_scoped_state_reapplying_after_conflict(
202
- &paths.run_workspace,
203
- &state,
204
- |latest| {
205
- for (task_id, result_id) in &task_updates {
206
- mark_task_done(latest, task_id, result_id);
207
- }
208
- },
209
- )?;
210
- } else {
211
- crate::state::persist::save_runtime_state_reapplying_after_conflict(
212
- &paths.run_workspace,
213
- &state,
214
- |latest| {
215
- for (task_id, result_id) in &task_updates {
216
- mark_task_done(latest, task_id, result_id);
217
- }
218
- },
219
- )?;
220
- }
200
+ crate::state::repository::StateRepository::new(&paths.run_workspace).save_reapplying(
201
+ crate::state::repository::StateWriteIntent::ResultCollection { owner_team_id },
202
+ &state,
203
+ |latest| {
204
+ for (task_id, result_id) in &task_updates {
205
+ mark_task_done(latest, task_id, result_id);
206
+ }
207
+ },
208
+ )?;
221
209
  }
222
210
  let counts = result_counts(&conn, owner_team_id)?;
223
211
  // results.py:157 — ensure_coordinator=true runs the REAL ensure step; the
@@ -870,7 +858,9 @@ fn report_result_for_owner_team_inner(
870
858
  }
871
859
  }
872
860
  if !outcome.ok {
873
- if !report_state_has_app_server_receiver(&delivery_state) {
861
+ if outcome.channel.as_deref() != Some("leader_acceptance_pending")
862
+ && !report_state_has_app_server_receiver(&delivery_state)
863
+ {
874
864
  let fallback_error = primary_error.unwrap_or_else(|| {
875
865
  format!(
876
866
  "leader_notification_primary_failed:{}",
@@ -892,9 +882,11 @@ fn report_result_for_owner_team_inner(
892
882
  }
893
883
  }
894
884
  }
895
- let leader_notified = outcome.ok;
896
- let notification_status_wire = if outcome.ok {
885
+ let leader_notified = matches!(outcome.status, crate::messaging::DeliveryStatus::Delivered);
886
+ let notification_status_wire = if leader_notified {
897
887
  "delivered"
888
+ } else if outcome.channel.as_deref() == Some("leader_acceptance_pending") {
889
+ "submitted_pending_acceptance"
898
890
  } else if outcome.channel.as_deref() == Some("rebind_required")
899
891
  || matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked)
900
892
  {
@@ -256,8 +256,10 @@ pub fn stuck_cancel(
256
256
  },
257
257
  );
258
258
  }
259
- crate::state::persist::save_runtime_state_reapplying_after_conflict(
260
- workspace,
259
+ crate::state::repository::StateRepository::new(workspace).save_reapplying(
260
+ crate::state::repository::StateWriteIntent::SchedulerSuppression {
261
+ owner_team_id: Some(&team),
262
+ },
261
263
  &state,
262
264
  |latest| {
263
265
  for kind in &alert_types {
@@ -129,18 +129,25 @@ fn run_contract_suite(
129
129
  }
130
130
  };
131
131
 
132
- match scratch_store.as_ref().and_then(|store| {
133
- store
134
- .create_message(
135
- None,
136
- "doctor",
137
- "worker",
138
- "comms contract probe",
139
- None,
140
- false,
141
- Some("contract-team"),
142
- )
143
- .ok()
132
+ match scratch_store.as_ref().and_then(|_| {
133
+ match super::persist::persist_internal_send(
134
+ &scratch,
135
+ super::InternalSendKind::Selftest,
136
+ Some("contract-team"),
137
+ None,
138
+ "doctor",
139
+ "worker",
140
+ "comms contract probe",
141
+ None,
142
+ false,
143
+ None,
144
+ super::InitialDisposition::Accepted,
145
+ )
146
+ .ok()?
147
+ {
148
+ super::PersistResolution::Persisted(persisted) => Some(persisted.message_id),
149
+ super::PersistResolution::Duplicate(_) => None,
150
+ }
144
151
  }) {
145
152
  Some(message_id) if message_id.starts_with("msg_") && message_id.len() > 4 => {
146
153
  let rendered = format!(
@@ -10,7 +10,11 @@ use crate::transport::{PaneId, Transport};
10
10
 
11
11
  use super::helpers::{status_wire, MessageStatusShadow};
12
12
  use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_message_id};
13
- use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
+ use super::{
14
+ persist_resolved_send, DeliveryBlocker, DeliveryOutcome, DeliveryRefusal, DeliveryStatus,
15
+ InitialDisposition, LogicalRecipient, MessagingError, PersistResolution, ResolvedSendIntent,
16
+ SendOrigin,
17
+ };
14
18
 
15
19
  /// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
16
20
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -49,6 +53,7 @@ impl TrustedSender {
49
53
  /// `send_message` 选项 (`send.py:36`:Python 大量默认参数 → typed 选项 struct)。
50
54
  #[derive(Debug, Clone)]
51
55
  pub struct SendOptions {
56
+ pub origin: SendOrigin,
52
57
  pub task_id: Option<TaskId>,
53
58
  /// `route_task_id`(golden `send.py:190`,默认 `True`):仅 **routing**(CLI `send --task`)时把
54
59
  /// `task_id` 当真任务校验/路由。**投递/fanout/internal/coordinator** 路径传 `false`
@@ -73,6 +78,7 @@ impl Default for SendOptions {
73
78
  fn default() -> Self {
74
79
  // 默认值对齐 Python 签名 (`send.py:38-40`)。
75
80
  Self {
81
+ origin: SendOrigin::Internal(super::InternalSendKind::Delivery),
76
82
  task_id: None,
77
83
  route_task_id: true,
78
84
  sender: TrustedSender::leader(),
@@ -207,46 +213,47 @@ pub fn send_message(
207
213
  }
208
214
  let stale_worker_target_missing =
209
215
  stale_worker_target_missing_preflight(workspace, &state, recipient)?;
210
- if !stale_worker_target_missing {
211
- if let Some(outcome) =
212
- coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
213
- {
214
- return Ok(outcome);
215
- }
216
+ let coordinator_unavailable = if stale_worker_target_missing {
217
+ None
218
+ } else {
219
+ coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
220
+ };
221
+ if let Some(gate) = coordinator_unavailable
222
+ .as_ref()
223
+ .filter(|gate| !gate.persist)
224
+ {
225
+ return Ok(gate.outcome.clone());
216
226
  }
217
- let store = crate::message_store::MessageStore::open(workspace)?;
218
- let task_id = opts.task_id.as_ref().map(|t| t.as_str());
219
- let owner_team_id = opts.team.as_ref().map(|t| t.as_str());
220
- // CR-015/054 caller-key dedup: if the caller supplied a stable id, an identical
221
- // re-send must NOT create a second row — return a Duplicate refusal instead.
222
- let message_id = if let Some(requested) = opts.message_id.as_deref() {
223
- if store.message_exists(requested)? {
227
+ let mut intent = ResolvedSendIntent::accepted(
228
+ opts.origin,
229
+ workspace,
230
+ opts.team.clone(),
231
+ LogicalRecipient::from_resolved(recipient),
232
+ opts.sender.clone(),
233
+ opts.task_id.clone(),
234
+ content,
235
+ None,
236
+ opts.requires_ack,
237
+ opts.message_id.clone(),
238
+ );
239
+ if coordinator_unavailable
240
+ .as_ref()
241
+ .is_some_and(|gate| gate.persist)
242
+ {
243
+ intent.initial_disposition =
244
+ InitialDisposition::Blocked(DeliveryBlocker::CoordinatorUnavailable);
245
+ }
246
+ let persisted = match persist_resolved_send(&intent)? {
247
+ PersistResolution::Persisted(persisted) => persisted,
248
+ PersistResolution::Duplicate(message_id) => {
224
249
  return Ok(refused_outcome_with_id(
225
250
  DeliveryRefusal::Duplicate,
226
- Some(requested.to_string()),
251
+ Some(message_id),
227
252
  ));
228
253
  }
229
- store.create_message_with_id(
230
- requested,
231
- task_id,
232
- opts.sender.as_str(),
233
- recipient,
234
- content,
235
- None,
236
- opts.requires_ack,
237
- owner_team_id,
238
- )?
239
- } else {
240
- store.create_message(
241
- task_id,
242
- opts.sender.as_str(),
243
- recipient,
244
- content,
245
- None,
246
- opts.requires_ack,
247
- owner_team_id,
248
- )?
249
254
  };
255
+ let message_id = persisted.message_id.clone();
256
+ let store = crate::message_store::MessageStore::open(workspace)?;
250
257
  if stale_worker_target_missing {
251
258
  return super::delivery::mark_worker_target_missing(
252
259
  &store,
@@ -256,10 +263,37 @@ pub fn send_message(
256
263
  None,
257
264
  );
258
265
  }
266
+ if let Some(CoordinatorUnavailableGate {
267
+ mut outcome,
268
+ persist: true,
269
+ }) = coordinator_unavailable
270
+ {
271
+ let Some(blocker) = persisted.blocker else {
272
+ return Err(MessagingError::Validation(
273
+ "coordinator-blocked persistence returned no blocker".to_string(),
274
+ ));
275
+ };
276
+ let message_status = persisted.row_status.as_str();
277
+ event_log.write(
278
+ "send.message_queued",
279
+ serde_json::json!({
280
+ "message_id": message_id,
281
+ "recipient": recipient,
282
+ "sender": opts.sender,
283
+ "message_status": message_status,
284
+ "blocker": blocker.as_str(),
285
+ }),
286
+ )?;
287
+ outcome.ok = true;
288
+ outcome.status = DeliveryStatus::Blocked;
289
+ outcome.message_status = MessageStatusShadow(message_status.to_string());
290
+ outcome.message_id = Some(message_id);
291
+ return Ok(outcome);
292
+ }
259
293
  Ok(DeliveryOutcome {
260
294
  ok: true,
261
295
  status: DeliveryStatus::Queued,
262
- message_status: MessageStatusShadow("accepted".to_string()),
296
+ message_status: MessageStatusShadow(persisted.row_status.as_str().to_string()),
263
297
  message_id: Some(message_id),
264
298
  verification: None,
265
299
  stage: None,
@@ -331,12 +365,17 @@ fn refused_outcome_with_verification(
331
365
  }
332
366
  }
333
367
 
368
+ struct CoordinatorUnavailableGate {
369
+ outcome: DeliveryOutcome,
370
+ persist: bool,
371
+ }
372
+
334
373
  fn coordinator_unavailable_outcome(
335
374
  workspace: &Path,
336
375
  recipient: &str,
337
376
  opts: &SendOptions,
338
377
  event_log: &EventLog,
339
- ) -> Result<Option<DeliveryOutcome>, MessagingError> {
378
+ ) -> Result<Option<CoordinatorUnavailableGate>, MessagingError> {
340
379
  let coordinator_workspace = WorkspacePath::new(workspace.to_path_buf());
341
380
  let health = crate::coordinator::coordinator_health(&coordinator_workspace);
342
381
  if health.ok || matches!(health.status, CoordinatorHealthStatus::Missing) {
@@ -366,9 +405,19 @@ fn coordinator_unavailable_outcome(
366
405
  )?;
367
406
  return Ok(None);
368
407
  }
369
- let warning = format!(
370
- "coordinator is not running; message was not queued for {recipient}. Run `team-agent diagnose` or restart the team before sending again."
408
+ let persist = !matches!(
409
+ health.metadata_mismatch_reason.as_deref(),
410
+ Some("protocol_version_mismatch" | "message_store_schema_version_mismatch")
371
411
  );
412
+ let warning = if persist {
413
+ format!(
414
+ "coordinator is not running; message was queued for {recipient} and will retry. Run `team-agent diagnose` or restart the team."
415
+ )
416
+ } else {
417
+ format!(
418
+ "coordinator protocol or schema is incompatible; message was not queued for {recipient}. Run `team-agent diagnose` or upgrade the runtime."
419
+ )
420
+ };
372
421
  event_log.write(
373
422
  "send.coordinator_unavailable",
374
423
  serde_json::json!({
@@ -377,22 +426,25 @@ fn coordinator_unavailable_outcome(
377
426
  "coordinator_status": health.status,
378
427
  "coordinator_pid": health.pid.map(|pid| pid.get()),
379
428
  "metadata_mismatch_reason": health.metadata_mismatch_reason,
380
- "message_queued": false,
429
+ "message_queued": persist,
381
430
  "warning": warning,
382
431
  "coordinator_log": crate::coordinator::coordinator_log_path(&coordinator_workspace)
383
432
  .display()
384
433
  .to_string(),
385
434
  }),
386
435
  )?;
387
- Ok(Some(DeliveryOutcome {
388
- ok: false,
389
- status: DeliveryStatus::Degraded,
390
- message_status: MessageStatusShadow("degraded".to_string()),
391
- message_id: None,
392
- verification: Some(warning),
393
- stage: None,
394
- reason: Some(DeliveryRefusal::CoordinatorUnavailable),
395
- channel: Some("coordinator_unavailable".to_string()),
436
+ Ok(Some(CoordinatorUnavailableGate {
437
+ persist,
438
+ outcome: DeliveryOutcome {
439
+ ok: false,
440
+ status: DeliveryStatus::Degraded,
441
+ message_status: MessageStatusShadow("degraded".to_string()),
442
+ message_id: None,
443
+ verification: Some(warning),
444
+ stage: None,
445
+ reason: Some(DeliveryRefusal::CoordinatorUnavailable),
446
+ channel: Some("coordinator_unavailable".to_string()),
447
+ },
396
448
  }))
397
449
  }
398
450
 
@@ -0,0 +1,169 @@
1
+ use super::*;
2
+ use crate::transport::test_support::OfflineTransport;
3
+ use std::path::Path;
4
+
5
+ fn pane(session: &str, tty: &str) -> PaneInfo {
6
+ PaneInfo {
7
+ pane_id: PaneId::new("%7"),
8
+ session: SessionName::new(session),
9
+ window_index: Some(0),
10
+ window_name: Some(WindowName::new("leader")),
11
+ pane_index: Some(0),
12
+ tty: Some(tty.to_string()),
13
+ current_command: Some("codex".to_string()),
14
+ current_path: None,
15
+ active: true,
16
+ pane_pid: Some(77),
17
+ leader_env: BTreeMap::new(),
18
+ }
19
+ }
20
+
21
+ fn receiver(socket: &str) -> serde_json::Value {
22
+ serde_json::json!({
23
+ "mode": "direct_tmux",
24
+ "status": "attached",
25
+ "pane_id": "%7",
26
+ "tmux_socket": socket,
27
+ "session_name": "old-session",
28
+ "window_name": "leader",
29
+ "pane_tty": "/dev/old",
30
+ "fingerprint": "old-session|0|0|/dev/old"
31
+ })
32
+ }
33
+
34
+ #[test]
35
+ fn live_direct_channel_accepts_session_and_tty_drift_as_diagnostics() {
36
+ let socket = "/tmp/team-agent-channel-a";
37
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
38
+ let transport = OfflineTransport::new()
39
+ .with_tmux_endpoint(socket)
40
+ .with_targets(vec![pane("new-session", "/dev/new")]);
41
+
42
+ let LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(channel)) =
43
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport)
44
+ else {
45
+ panic!("same socket and pane must remain a live channel");
46
+ };
47
+
48
+ assert_eq!(channel.pane_id, "%7");
49
+ assert_eq!(channel.tmux_socket.as_deref(), Some(socket));
50
+ assert_eq!(
51
+ channel.metadata_drift,
52
+ vec!["session_name", "pane_tty", "fingerprint"]
53
+ );
54
+ }
55
+
56
+ #[test]
57
+ fn legacy_receiver_without_status_still_uses_live_channel_facts() {
58
+ let socket = "/tmp/team-agent-channel-a";
59
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
60
+ let mut receiver = receiver(socket);
61
+ if let Some(receiver) = receiver.as_object_mut() {
62
+ receiver.remove("status");
63
+ }
64
+ let transport = OfflineTransport::new()
65
+ .with_tmux_endpoint(socket)
66
+ .with_targets(vec![pane("new-session", "/dev/new")]);
67
+
68
+ assert!(matches!(
69
+ resolve_live_leader_channel(workspace, &receiver, &transport),
70
+ LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(_))
71
+ ));
72
+ }
73
+
74
+ #[test]
75
+ fn explicit_non_attached_status_still_refuses_before_liveness() {
76
+ let socket = "/tmp/team-agent-channel-a";
77
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
78
+ let mut receiver = receiver(socket);
79
+ receiver["status"] = serde_json::json!("rebind_required");
80
+ let transport = OfflineTransport::new()
81
+ .with_tmux_endpoint(socket)
82
+ .with_targets(vec![pane("new-session", "/dev/new")]);
83
+
84
+ assert_eq!(
85
+ resolve_live_leader_channel(workspace, &receiver, &transport),
86
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached)
87
+ );
88
+ }
89
+
90
+ #[test]
91
+ fn legacy_receiver_without_status_still_rejects_a_foreign_workspace() {
92
+ let socket = "/tmp/team-agent-channel-a";
93
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
94
+ let mut receiver = receiver(socket);
95
+ if let Some(receiver) = receiver.as_object_mut() {
96
+ receiver.remove("status");
97
+ }
98
+ let mut observed = pane("foreign-session", "/dev/foreign");
99
+ observed.current_path = Some(Path::new("/tmp/team-agent-workspace-b").to_path_buf());
100
+ let transport = OfflineTransport::new()
101
+ .with_tmux_endpoint(socket)
102
+ .with_targets(vec![observed]);
103
+
104
+ assert_eq!(
105
+ resolve_live_leader_channel(workspace, &receiver, &transport),
106
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch)
107
+ );
108
+ }
109
+
110
+ #[test]
111
+ fn same_pane_id_on_foreign_socket_is_not_a_live_channel() {
112
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
113
+ let transport = OfflineTransport::new()
114
+ .with_tmux_endpoint("/tmp/team-agent-channel-b")
115
+ .with_targets(vec![pane("old-session", "/dev/old")]);
116
+
117
+ assert_eq!(
118
+ resolve_live_leader_channel(
119
+ workspace,
120
+ &receiver("/tmp/team-agent-channel-a"),
121
+ &transport,
122
+ ),
123
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch)
124
+ );
125
+ }
126
+
127
+ #[test]
128
+ fn attached_record_without_live_pane_is_not_a_live_channel() {
129
+ let socket = "/tmp/team-agent-channel-a";
130
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
131
+ let transport = OfflineTransport::new().with_tmux_endpoint(socket);
132
+
133
+ assert_eq!(
134
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport),
135
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive)
136
+ );
137
+ }
138
+
139
+ #[test]
140
+ fn live_direct_channel_accepts_a_pane_inside_the_target_workspace() {
141
+ let socket = "/tmp/team-agent-channel-a";
142
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
143
+ let mut observed = pane("new-session", "/dev/new");
144
+ observed.current_path = Some(workspace.join("nested"));
145
+ let transport = OfflineTransport::new()
146
+ .with_tmux_endpoint(socket)
147
+ .with_targets(vec![observed]);
148
+
149
+ assert!(matches!(
150
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport),
151
+ LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(_))
152
+ ));
153
+ }
154
+
155
+ #[test]
156
+ fn same_socket_and_pane_in_a_foreign_workspace_is_not_a_live_leader() {
157
+ let socket = "/tmp/team-agent-channel-a";
158
+ let workspace = Path::new("/tmp/team-agent-workspace-a");
159
+ let mut observed = pane("foreign-session", "/dev/foreign");
160
+ observed.current_path = Some(Path::new("/tmp/team-agent-workspace-b").to_path_buf());
161
+ let transport = OfflineTransport::new()
162
+ .with_tmux_endpoint(socket)
163
+ .with_targets(vec![observed]);
164
+
165
+ assert_eq!(
166
+ resolve_live_leader_channel(workspace, &receiver(socket), &transport),
167
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch)
168
+ );
169
+ }