@team-agent/installer 0.5.77 → 0.5.78

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 (83) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +362 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  13. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  14. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  15. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  16. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  17. package/crates/team-agent/src/cli/types.rs +1 -0
  18. package/crates/team-agent/src/leader/lease.rs +492 -33
  19. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  20. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  21. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  22. package/crates/team-agent/src/leader/start.rs +151 -14
  23. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  24. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  25. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  26. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  27. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  28. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +65 -18
  29. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  30. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  31. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  32. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  33. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  34. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  35. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  38. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  39. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  40. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  41. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  42. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  43. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  44. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  45. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  46. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  47. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  48. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  49. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  50. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  51. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  52. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  53. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  54. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  55. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  56. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  57. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  58. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  59. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  60. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  61. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  63. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  64. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  65. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  66. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  67. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  68. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  69. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  70. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  71. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  72. package/crates/team-agent/src/state/persist.rs +97 -1
  73. package/crates/team-agent/src/state/repository.rs +29 -0
  74. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  75. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  76. package/crates/team-agent/src/tmux_backend.rs +208 -118
  77. package/crates/team-agent/src/transport.rs +72 -0
  78. package/crates/team-agent/src/transport_factory.rs +53 -0
  79. package/package.json +4 -5
  80. package/skills/team-agent/SKILL.md +45 -17
  81. package/skills/team-agent/command-coverage.json +4 -29
  82. package/npm/check-skill-links.mjs +0 -88
  83. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -185,7 +185,7 @@ fn run_stdio_loop<R: BufRead, W: Write>(
185
185
  let mut report = ServerRunReport::default();
186
186
  let marker = McpServerLifecycleMarker::from_env(workspace);
187
187
  marker.write_started();
188
- let result = run_stdio_loop_inner(&tools, reader, &mut writer, &mut report);
188
+ let result = run_stdio_loop_inner(&tools, reader, &mut writer, &mut report, &marker);
189
189
  match &result {
190
190
  Ok(_) => marker.write_exit("stdin_eof", None),
191
191
  Err(error) => marker.write_exit("fatal_error", Some(&error.to_string())),
@@ -198,16 +198,20 @@ fn run_stdio_loop_inner<R: BufRead, W: Write>(
198
198
  reader: R,
199
199
  writer: &mut W,
200
200
  report: &mut ServerRunReport,
201
+ marker: &McpServerLifecycleMarker,
201
202
  ) -> Result<ServerRunReport, McpError> {
202
203
  for line in reader.lines() {
203
204
  let line = line?;
204
205
  report.requests_read = report.requests_read.saturating_add(1);
205
- let frame = handle_stdin_line(tools, &line, report)?;
206
+ let (frame, tools_list_success) = handle_stdin_line(tools, &line, report)?;
206
207
  if let Some(value) = frame {
207
208
  let value = crate::redaction::redact_external_value(&value);
208
209
  serde_json::to_writer(&mut *writer, &value)?;
209
210
  writer.write_all(b"\n")?;
210
211
  writer.flush()?;
212
+ if tools_list_success {
213
+ marker.write_tools_list_response_written();
214
+ }
211
215
  report.responses_written = report.responses_written.saturating_add(1);
212
216
  }
213
217
  }
@@ -219,29 +223,34 @@ fn handle_stdin_line(
219
223
  tools: &TeamOrchestratorTools,
220
224
  line: &str,
221
225
  report: &mut ServerRunReport,
222
- ) -> Result<Option<Value>, McpError> {
226
+ ) -> Result<(Option<Value>, bool), McpError> {
223
227
  let request: Value = match serde_json::from_str(line) {
224
228
  Ok(value) => value,
225
229
  Err(err) => {
226
230
  report.error_frames = report.error_frames.saturating_add(1);
227
- return Ok(Some(error_response_value(
228
- RpcId::Null,
229
- -32700,
230
- format!("parse error: {err}"),
231
- )));
231
+ return Ok((
232
+ Some(error_response_value(
233
+ RpcId::Null,
234
+ -32700,
235
+ format!("parse error: {err}"),
236
+ )),
237
+ false,
238
+ ));
232
239
  }
233
240
  };
234
241
  if request.get("jsonrpc").and_then(Value::as_str) == Some("2.0") {
242
+ let is_tools_list = request.get("method").and_then(Value::as_str) == Some("tools/list");
235
243
  match handle_mcp(tools, &request)? {
236
244
  Some(response) => {
245
+ let tools_list_success = is_tools_list && response.error.is_none();
237
246
  if response.error.is_some() {
238
247
  report.error_frames = report.error_frames.saturating_add(1);
239
248
  }
240
- Ok(Some(serde_json::to_value(response)?))
249
+ Ok((Some(serde_json::to_value(response)?), tools_list_success))
241
250
  }
242
251
  None => {
243
252
  report.notifications_skipped = report.notifications_skipped.saturating_add(1);
244
- Ok(None)
253
+ Ok((None, false))
245
254
  }
246
255
  }
247
256
  } else {
@@ -252,7 +261,7 @@ fn handle_stdin_line(
252
261
  err.to_envelope()
253
262
  }
254
263
  };
255
- Ok(Some(value))
264
+ Ok((Some(value), false))
256
265
  }
257
266
  }
258
267
 
@@ -301,6 +310,23 @@ impl McpServerLifecycleMarker {
301
310
  self.write("mcp.server_started", None, None);
302
311
  }
303
312
 
313
+ fn write_tools_list_response_written(&self) {
314
+ let _ = EventLog::new(&self.workspace).write(
315
+ "mcp.tools_list_response_written",
316
+ serde_json::json!({
317
+ "method": "tools/list",
318
+ "success": true,
319
+ "agent_id": self.agent_id.as_deref(),
320
+ "owner_team_id": self.owner_team_id.as_deref(),
321
+ "workspace": self.workspace.display().to_string(),
322
+ "pid": self.pid,
323
+ "ppid": self.ppid,
324
+ "spawn_epoch": null,
325
+ "spawn_epoch_source": "unavailable",
326
+ }),
327
+ );
328
+ }
329
+
304
330
  fn write_exit(&self, reason: &str, error: Option<&str>) {
305
331
  self.write("mcp.server_exit", Some(reason), error);
306
332
  }
@@ -798,6 +824,109 @@ mod e23_lifecycle_marker_tests {
798
824
  path
799
825
  }
800
826
 
827
+ #[test]
828
+ fn stdio_loop_logs_tools_list_after_flush_once_per_response() {
829
+ let ws = marker_ws("tools-list-success");
830
+ let input = std::io::Cursor::new(
831
+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}
832
+ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
833
+ "#.to_vec(),
834
+ );
835
+ let mut output = Vec::new();
836
+
837
+ let report = run_stdio_loop(&ws, input, &mut output).unwrap();
838
+
839
+ assert_eq!(report.responses_written, 2);
840
+ assert_eq!(output.iter().filter(|byte| **byte == b'\n').count(), 2);
841
+ let events = EventLog::new(&ws).tail(0).unwrap();
842
+ let tool_events: Vec<_> = events
843
+ .iter()
844
+ .filter(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written"))
845
+ .collect();
846
+ assert_eq!(tool_events.len(), 2, "one event per successful tools/list response");
847
+ for event in tool_events {
848
+ assert_eq!(event["method"], serde_json::json!("tools/list"));
849
+ assert_eq!(event["success"], serde_json::json!(true));
850
+ assert_eq!(event["workspace"], serde_json::json!(ws.display().to_string()));
851
+ assert_eq!(event["pid"], serde_json::json!(std::process::id()));
852
+ assert_eq!(event["spawn_epoch"], serde_json::Value::Null);
853
+ }
854
+ let _ = std::fs::remove_dir_all(&ws);
855
+ }
856
+
857
+ #[test]
858
+ fn stdio_loop_does_not_log_tools_list_when_write_fails() {
859
+ struct FailingWriter;
860
+ impl Write for FailingWriter {
861
+ fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
862
+ Err(std::io::Error::other("simulated stdout write failure"))
863
+ }
864
+
865
+ fn flush(&mut self) -> std::io::Result<()> {
866
+ Ok(())
867
+ }
868
+ }
869
+
870
+ let ws = marker_ws("tools-list-write-fail");
871
+ let input = std::io::Cursor::new(
872
+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_vec(),
873
+ );
874
+ let error = run_stdio_loop(&ws, input, FailingWriter).expect_err("write must fail");
875
+ assert!(error.to_string().contains("simulated stdout write failure"));
876
+ let events = EventLog::new(&ws).tail(0).unwrap();
877
+ assert!(!events
878
+ .iter()
879
+ .any(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written")));
880
+ let _ = std::fs::remove_dir_all(&ws);
881
+ }
882
+
883
+ #[test]
884
+ fn stdio_loop_does_not_log_tools_list_when_flush_fails() {
885
+ struct FlushFailWriter(Vec<u8>);
886
+ impl Write for FlushFailWriter {
887
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
888
+ self.0.extend_from_slice(buf);
889
+ Ok(buf.len())
890
+ }
891
+
892
+ fn flush(&mut self) -> std::io::Result<()> {
893
+ Err(std::io::Error::other("simulated stdout flush failure"))
894
+ }
895
+ }
896
+
897
+ let ws = marker_ws("tools-list-flush-fail");
898
+ let input = std::io::Cursor::new(
899
+ br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_vec(),
900
+ );
901
+ let error = run_stdio_loop(&ws, input, FlushFailWriter(Vec::new()))
902
+ .expect_err("flush must fail");
903
+ assert!(error.to_string().contains("simulated stdout flush failure"));
904
+ let events = EventLog::new(&ws).tail(0).unwrap();
905
+ assert!(!events
906
+ .iter()
907
+ .any(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written")));
908
+ let _ = std::fs::remove_dir_all(&ws);
909
+ }
910
+
911
+ #[test]
912
+ fn stdio_loop_does_not_log_tools_list_for_initialize_or_notification() {
913
+ let ws = marker_ws("tools-list-not-applicable");
914
+ let input = std::io::Cursor::new(
915
+ br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}
916
+ {"jsonrpc":"2.0","method":"notifications/initialized"}
917
+ "#.to_vec(),
918
+ );
919
+ let mut output = Vec::new();
920
+
921
+ run_stdio_loop(&ws, input, &mut output).unwrap();
922
+
923
+ let events = EventLog::new(&ws).tail(0).unwrap();
924
+ assert!(!events
925
+ .iter()
926
+ .any(|event| event["event"] == serde_json::json!("mcp.tools_list_response_written")));
927
+ let _ = std::fs::remove_dir_all(&ws);
928
+ }
929
+
801
930
  #[test]
802
931
  fn stdio_loop_writes_started_and_stdin_eof_exit_markers() {
803
932
  let ws = marker_ws("eof");
@@ -31,9 +31,10 @@ use crate::provider::wire::{
31
31
  is_claude_family, parse_canonical_provider, parse_provider, provider_wire,
32
32
  };
33
33
  use crate::transport::{
34
- submit_verification_wire, turn_verification_wire, CaptureRange, CaptureSampleOutcome,
35
- InjectPayload, InjectReport, InjectVerification, InputSurfaceProbe, Key, PaneId, PaneInfo,
36
- QueryOutcome, SessionName, SubmitObserver, SubmitVerification, Target, Transport, WindowName,
34
+ submit_verification_wire, turn_verification_wire, BusySignalKind, CaptureRange,
35
+ CaptureSampleOutcome, InjectPayload, InjectReport, InjectVerification, InputSurfaceProbe, Key,
36
+ PaneId, PaneInfo, QueryOutcome, SessionName, SubmitConsumptionReason, SubmitObserver,
37
+ SubmitVerification, Target, Transport, WindowName,
37
38
  };
38
39
 
39
40
  use super::helpers::{message_exists, MessageStatusShadow};
@@ -572,11 +573,11 @@ pub fn deliver_pending_message(
572
573
  canonical_owner_team_id.as_deref(),
573
574
  resolved.metadata.as_ref(),
574
575
  );
575
- let recipient_single_enter = recipient_requires_single_enter(state, &message.recipient);
576
+ let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
576
577
  let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
577
578
  paste_to_submit_floor_for_recipient(state, &message.recipient),
578
579
  || {
579
- crate::tmux_backend::with_cursor_single_enter(recipient_single_enter, || {
580
+ crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
580
581
  transport.inject_with_submit_observer(
581
582
  &target,
582
583
  &payload,
@@ -592,9 +593,7 @@ pub fn deliver_pending_message(
592
593
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
593
594
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
594
595
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
595
- if !crate::provider::submit_now::keep_provider_queue_requested()
596
- && recipient_allows_explicit_queue_flush(state, &message.recipient)
597
- {
596
+ if !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
598
597
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
599
598
  Ok(flush) if flush.extra_enters > 0 => {
600
599
  let _ = event_log.write(
@@ -1999,6 +1998,36 @@ fn merge_inject_readback_fields(
1999
1998
  .map(|d| d.last_capture_outcome.as_str())
2000
1999
  .unwrap_or(CaptureSampleOutcome::NotAttempted.as_str())),
2001
2000
  );
2001
+ obj.insert(
2002
+ "consumption_reason".to_string(),
2003
+ serde_json::json!(diag
2004
+ .map(|d| d.consumption_reason.as_str())
2005
+ .unwrap_or("unknown")),
2006
+ );
2007
+ obj.insert(
2008
+ "before_busy_signal_kind".to_string(),
2009
+ serde_json::json!(diag.and_then(|d| d.before_busy_signal_kind.map(|kind| kind.as_str()))),
2010
+ );
2011
+ obj.insert(
2012
+ "busy_signal_kind".to_string(),
2013
+ serde_json::json!(diag.and_then(|d| d.busy_signal_kind.map(|kind| kind.as_str()))),
2014
+ );
2015
+ obj.insert(
2016
+ "busy_line_from_bottom".to_string(),
2017
+ serde_json::json!(diag.and_then(|d| d.busy_line_from_bottom)),
2018
+ );
2019
+ obj.insert(
2020
+ "current_marker_in_bottom_15".to_string(),
2021
+ serde_json::json!(diag.and_then(|d| d.current_marker_in_bottom_15)),
2022
+ );
2023
+ obj.insert(
2024
+ "paste_identity_in_composer".to_string(),
2025
+ serde_json::json!(diag.and_then(|d| d.paste_identity_in_composer)),
2026
+ );
2027
+ obj.insert(
2028
+ "consumption_from_capture_result".to_string(),
2029
+ serde_json::json!(diag.and_then(|d| d.consumption_from_capture_result)),
2030
+ );
2002
2031
  obj.insert(
2003
2032
  "pane_command_basename".to_string(),
2004
2033
  diag.and_then(|d| d.pane_command_basename.as_deref())
@@ -2357,6 +2386,13 @@ fn recipient_provider(state: &serde_json::Value, recipient: &str) -> Option<Prov
2357
2386
  .and_then(parse_canonical_provider)
2358
2387
  }
2359
2388
 
2389
+ fn recipient_is_cursor_agent(state: &serde_json::Value, recipient: &str) -> bool {
2390
+ matches!(
2391
+ recipient_provider(state, recipient),
2392
+ Some(Provider::CursorAgent)
2393
+ )
2394
+ }
2395
+
2360
2396
  /// ---
2361
2397
  /// purpose: paste→Enter 地板按收件人 provider 选择
2362
2398
  /// contract: CursorAgent 与 Grok 为 1s;其余(含 claude)为 ZERO
@@ -3391,6 +3427,13 @@ mod paste_floor_tests {
3391
3427
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
3392
3428
  attempts: 1,
3393
3429
  submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
3430
+ consumption_reason: SubmitConsumptionReason::Unverified,
3431
+ before_busy_signal_kind: Some(BusySignalKind::Working),
3432
+ busy_signal_kind: Some(BusySignalKind::SpinnerGlyph),
3433
+ busy_line_from_bottom: Some(2),
3434
+ current_marker_in_bottom_15: Some(false),
3435
+ paste_identity_in_composer: None,
3436
+ consumption_from_capture_result: Some(false),
3394
3437
  last_capture_outcome: CaptureSampleOutcome::Failed,
3395
3438
  capture_err_count: 1,
3396
3439
  capture_sample_count: 1,
@@ -3419,6 +3462,22 @@ mod paste_floor_tests {
3419
3462
  None,
3420
3463
  );
3421
3464
  assert_eq!(event["last_capture_outcome"], serde_json::json!("failed"));
3465
+ assert_eq!(event["consumption_reason"], serde_json::json!("unverified"));
3466
+ assert_eq!(
3467
+ event["before_busy_signal_kind"],
3468
+ serde_json::json!("working")
3469
+ );
3470
+ assert_eq!(event["busy_signal_kind"], serde_json::json!("spinner_glyph"));
3471
+ assert_eq!(event["busy_line_from_bottom"], serde_json::json!(2));
3472
+ assert_eq!(
3473
+ event["current_marker_in_bottom_15"],
3474
+ serde_json::json!(false)
3475
+ );
3476
+ assert_eq!(event["paste_identity_in_composer"], serde_json::Value::Null);
3477
+ assert_eq!(
3478
+ event["consumption_from_capture_result"],
3479
+ serde_json::json!(false)
3480
+ );
3422
3481
  assert_eq!(event["token_seen_after_paste"], serde_json::Value::Null);
3423
3482
  assert_eq!(event["pane_command_basename"], serde_json::json!("grok"));
3424
3483
  assert_eq!(event["input_surface"], serde_json::json!("input"));
@@ -229,3 +229,59 @@ fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
229
229
  .and_then(Value::as_str)
230
230
  .filter(|value| !value.is_empty())
231
231
  }
232
+
233
+ #[cfg(test)]
234
+ mod claim_rework_live_tests {
235
+ use super::*;
236
+ use crate::transport::test_support::OfflineTransport;
237
+ use crate::transport::{PaneId, PaneInfo, SessionName};
238
+ use serde_json::json;
239
+ use std::path::PathBuf;
240
+
241
+ #[test]
242
+ fn pending_receiver_is_not_a_live_channel() {
243
+ let workspace = PathBuf::from("/tmp/claim-rework-a3-pending");
244
+ let receiver = json!({
245
+ "mode": "direct_tmux",
246
+ "status": "pending",
247
+ "pane_id": "%1",
248
+ "tmux_socket": "/tmp/ta-offline.sock"
249
+ });
250
+ let transport = OfflineTransport::default().with_tmux_endpoint("/tmp/ta-offline.sock");
251
+ match resolve_live_leader_channel(&workspace, &receiver, &transport) {
252
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached) => {}
253
+ other => panic!("pending seed must not be live; got {other:?}"),
254
+ }
255
+ }
256
+
257
+ #[test]
258
+ fn dead_pane_with_matching_endpoint_is_pane_not_live() {
259
+ let workspace = PathBuf::from("/tmp/claim-rework-a3-dead");
260
+ let endpoint = "/tmp/ta-claim-rework-a3.sock";
261
+ let receiver = json!({
262
+ "mode": "direct_tmux",
263
+ "status": "attached",
264
+ "pane_id": "%missing",
265
+ "tmux_socket": endpoint
266
+ });
267
+ let transport = OfflineTransport::default()
268
+ .with_tmux_endpoint(endpoint)
269
+ .with_targets(vec![PaneInfo {
270
+ pane_id: PaneId::new("%other"),
271
+ session: SessionName::new("team"),
272
+ window_index: None,
273
+ window_name: None,
274
+ pane_index: None,
275
+ tty: None,
276
+ current_command: None,
277
+ current_path: None,
278
+ active: true,
279
+ pane_pid: None,
280
+ leader_env: Default::default(),
281
+ }]);
282
+ match resolve_live_leader_channel(&workspace, &receiver, &transport) {
283
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive) => {}
284
+ other => panic!("matching endpoint + missing pane must be PaneNotLive; got {other:?}"),
285
+ }
286
+ }
287
+ }
@@ -3376,10 +3376,18 @@ fn gate054_status_surfaces_pending_leader_notifications() {
3376
3376
  entry.get("channel").and_then(serde_json::Value::as_str),
3377
3377
  Some("rebind_required")
3378
3378
  );
3379
- assert!(entry
3379
+ let action = entry
3380
3380
  .get("action")
3381
3381
  .and_then(serde_json::Value::as_str)
3382
- .is_some_and(|action| action.contains("attach-leader") || action.contains("takeover")));
3382
+ .unwrap_or("");
3383
+ assert!(
3384
+ action.contains("historical leader mailbox debt"),
3385
+ "historical debt action must stay debt-scoped; action={action}"
3386
+ );
3387
+ assert!(
3388
+ !action.contains("attach-leader") && !action.contains("takeover") && !action.contains("claim-leader"),
3389
+ "historical debt must not authorize bind commands; action={action}"
3390
+ );
3383
3391
  }
3384
3392
 
3385
3393
  #[test]
@@ -15,10 +15,8 @@
15
15
  //! `.team/scripts/cursor_seat.sh` 实测一致:
16
16
  //! `--trust --sandbox disabled --workspace <物理路径> [--force] [--model]`
17
17
  //! Role 不入 argv,写 `<workspace>/.cursor/rules/*.mdc` + `alwaysApply: true`。
18
- //! MCP 无 `--mcp-config`;身份必须写进 per-seat
19
- //! `provider-config/<id>/cursor/.cursor/mcp.json` 的 env 表
18
+ //! MCP 无 `--mcp-config`;身份必须写进 `.cursor/mcp.json` 的 env 表
20
19
  //! (cursor 不把父进程 TEAM_AGENT_* 传给 MCP 子进程)。
21
- //! spawn 把 `--workspace` 指到该工程根,并用 `--add-dir` 挂上真 workspace。
22
20
  //!
23
21
  //! 隐藏 flag `--system-prompt` / `--allowed-tools` 实测存在但 help 未列,
24
22
  //! 随时可能消失。可以在注释里记录,代码里不许调。
@@ -75,8 +73,7 @@ pub(crate) fn cursor_agent_base_command(
75
73
  let _ = effort;
76
74
  // system_prompt 不入 argv(help 无 --rules / --append-system-prompt)。
77
75
  // launch 写 `<workspace>/.cursor/rules/team-agent-role-<agent_id>.mdc`。
78
- // mcp_config 也不入 argv(无 --mcp-config)。launch 写
79
- // `.team/runtime/provider-config/<id>/cursor/.cursor/mcp.json`
76
+ // mcp_config 也不入 argv(无 --mcp-config)。launch 写 `.cursor/mcp.json`
80
77
  // 并 `agent mcp enable team_orchestrator`。这里收下以免静默丢弃。
81
78
  let _ = (adapter, auth_mode, mcp_config, managed_mcp_config, system_prompt);
82
79
  argv.push("--workspace".to_string());
@@ -377,6 +377,7 @@ fn save_runtime_state_with_merge_options(
377
377
  exact_owner_seed_to_clear,
378
378
  None,
379
379
  None,
380
+ None,
380
381
  )
381
382
  }
382
383
 
@@ -399,6 +400,7 @@ pub(crate) fn save_runtime_state_with_receiver_authority_and_expected(
399
400
  None,
400
401
  Some((expected_owner, expected_receiver)),
401
402
  None,
403
+ None,
402
404
  )
403
405
  }
404
406
 
@@ -421,6 +423,38 @@ pub(crate) fn save_runtime_state_with_exact_owner_receiver_cleanup(
421
423
  None,
422
424
  None,
423
425
  Some((team_key, expected_owner, expected_receiver)),
426
+ None,
427
+ )
428
+ }
429
+
430
+ pub(crate) fn save_runtime_state_with_exact_owner_receiver_restore(
431
+ workspace: &Path,
432
+ state: &Value,
433
+ team_key: &str,
434
+ expected_owner: &Value,
435
+ expected_receiver: &Value,
436
+ previous_owner: &Value,
437
+ previous_receiver: &Value,
438
+ ) -> Result<(), StateError> {
439
+ persist_runtime_state_with_merge_options_and_expected(
440
+ workspace,
441
+ state,
442
+ &[],
443
+ None,
444
+ &[],
445
+ None,
446
+ &[],
447
+ Some(team_key),
448
+ None,
449
+ None,
450
+ None,
451
+ Some((
452
+ team_key,
453
+ expected_owner,
454
+ expected_receiver,
455
+ previous_owner,
456
+ previous_receiver,
457
+ )),
424
458
  )
425
459
  }
426
460
 
@@ -436,6 +470,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
436
470
  exact_owner_seed_to_clear: Option<&Value>,
437
471
  expected_owner_receiver: Option<(&Value, &Value)>,
438
472
  exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
473
+ exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
439
474
  ) -> Result<(), StateError> {
440
475
  let path = runtime_state_path(workspace);
441
476
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -459,7 +494,8 @@ fn persist_runtime_state_with_merge_options_and_expected(
459
494
  // and leave the seed, or skip copying a concurrent owner.
460
495
  let skip_pre_lock_fast_path = exact_owner_seed_to_clear.is_some()
461
496
  || expected_owner_receiver.is_some()
462
- || exact_owner_receiver_to_clear.is_some();
497
+ || exact_owner_receiver_to_clear.is_some()
498
+ || exact_owner_receiver_to_restore.is_some();
463
499
  if !skip_pre_lock_fast_path && cache_equals(&path, &migrated) {
464
500
  return Ok(());
465
501
  }
@@ -544,6 +580,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
544
580
  &receiver_updates,
545
581
  receiver_update_team_key.zip(exact_owner_seed_to_clear),
546
582
  exact_owner_receiver_to_clear,
583
+ exact_owner_receiver_to_restore,
547
584
  )?;
548
585
  }
549
586
  // Stage 3 save-output strip second pass (defence-in-depth): after the
@@ -613,6 +650,7 @@ fn apply_persist_merge_contract(
613
650
  receiver_updates: &BTreeSet<String>,
614
651
  exact_owner_seed_to_clear: Option<(&str, &Value)>,
615
652
  exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
653
+ exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
616
654
  ) -> Result<(), StateError> {
617
655
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
618
656
  // the top-level<->active-team cross projections depend on which team is active); the
@@ -745,6 +783,25 @@ fn apply_persist_merge_contract(
745
783
  );
746
784
  }
747
785
  }
786
+ if let Some((
787
+ restore_team,
788
+ expected_owner,
789
+ expected_receiver,
790
+ previous_owner,
791
+ previous_receiver,
792
+ )) = exact_owner_receiver_to_restore
793
+ {
794
+ if team == restore_team {
795
+ apply_lock_held_exact_owner_receiver_restore(
796
+ incoming_entry,
797
+ latest_entry,
798
+ expected_owner,
799
+ expected_receiver,
800
+ previous_owner,
801
+ previous_receiver,
802
+ );
803
+ }
804
+ }
748
805
  preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
749
806
  }
750
807
  }
@@ -919,6 +976,43 @@ fn apply_lock_held_exact_owner_receiver_cleanup(
919
976
  }
920
977
  }
921
978
 
979
+ fn apply_lock_held_exact_owner_receiver_restore(
980
+ incoming: &mut Value,
981
+ latest: &Value,
982
+ expected_owner: &Value,
983
+ expected_receiver: &Value,
984
+ previous_owner: &Value,
985
+ previous_receiver: &Value,
986
+ ) {
987
+ let latest_owner = latest.get("team_owner");
988
+ let latest_receiver = latest.get("leader_receiver");
989
+ let Some(incoming_obj) = incoming.as_object_mut() else {
990
+ return;
991
+ };
992
+ if latest_owner == Some(expected_owner) && latest_receiver == Some(expected_receiver) {
993
+ incoming_obj.insert("team_owner".to_string(), previous_owner.clone());
994
+ incoming_obj.insert("leader_receiver".to_string(), previous_receiver.clone());
995
+ if let Some(epoch) = previous_owner
996
+ .get("owner_epoch")
997
+ .cloned()
998
+ .or_else(|| latest.get("owner_epoch").cloned())
999
+ {
1000
+ incoming_obj.insert("owner_epoch".to_string(), epoch);
1001
+ }
1002
+ return;
1003
+ }
1004
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1005
+ match latest.get(key) {
1006
+ Some(value) => {
1007
+ incoming_obj.insert(key.to_string(), value.clone());
1008
+ }
1009
+ None => {
1010
+ incoming_obj.remove(key);
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+
922
1016
  fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
923
1017
  if !latest_has_preferable_endpoint_convergence(incoming, latest) {
924
1018
  return;
@@ -3113,6 +3207,7 @@ mod tests {
3113
3207
  &BTreeSet::new(),
3114
3208
  None,
3115
3209
  None,
3210
+ None,
3116
3211
  )
3117
3212
  .unwrap();
3118
3213
 
@@ -3149,6 +3244,7 @@ mod tests {
3149
3244
  &receiver_updates,
3150
3245
  None,
3151
3246
  None,
3247
+ None,
3152
3248
  )
3153
3249
  .unwrap();
3154
3250
 
@@ -52,6 +52,8 @@ use super::persist::{
52
52
  as helper_write_root_with_receiver_authority_and_expected,
53
53
  save_runtime_state_with_exact_owner_receiver_cleanup
54
54
  as helper_write_root_with_exact_owner_receiver_cleanup,
55
+ save_runtime_state_with_exact_owner_receiver_restore
56
+ as helper_write_root_with_exact_owner_receiver_restore,
55
57
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
56
58
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
57
59
  save_runtime_state_without_migrations as helper_write_root_without_migrations,
@@ -224,9 +226,20 @@ pub enum StateWriteIntent<'a> {
224
226
  seed: &'a Value,
225
227
  expected_receiver: &'a Value,
226
228
  },
229
+ RestoreExactTeamOwnerAndReceiver {
230
+ team_key: &'a str,
231
+ expected_owner: &'a Value,
232
+ expected_receiver: &'a Value,
233
+ previous_owner: &'a Value,
234
+ previous_receiver: &'a Value,
235
+ },
227
236
  LeaderBindingRestoreNonTargetTeams {
228
237
  target_team_key: &'a str,
229
238
  },
239
+ /// Strip extra keys written by a failed or degraded quick-start bind attempt.
240
+ QuickStartBindingCleanup {
241
+ team_key: &'a str,
242
+ },
230
243
  LeaderStartBinding {
231
244
  team_key: &'a str,
232
245
  transport_kind: &'a str,
@@ -428,6 +441,22 @@ fn route_direct(
428
441
  seed,
429
442
  expected_receiver,
430
443
  ),
444
+ StateWriteIntent::RestoreExactTeamOwnerAndReceiver {
445
+ team_key,
446
+ expected_owner,
447
+ expected_receiver,
448
+ previous_owner,
449
+ previous_receiver,
450
+ } => helper_write_root_with_exact_owner_receiver_restore(
451
+ workspace,
452
+ state,
453
+ team_key,
454
+ expected_owner,
455
+ expected_receiver,
456
+ previous_owner,
457
+ previous_receiver,
458
+ ),
459
+ StateWriteIntent::QuickStartBindingCleanup { .. } => helper_write_root(workspace, state),
431
460
  StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
432
461
  helper_write_root_without_migrations(workspace, state)
433
462
  }