@team-agent/installer 0.5.66 → 0.5.68

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 (164) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/cli/adapters.rs +5 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +82 -0
  6. package/crates/team-agent/src/cli/emit.rs +3 -3
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +21 -7
  9. package/crates/team-agent/src/cli/leaders.rs +125 -1
  10. package/crates/team-agent/src/cli/mod.rs +20 -0
  11. package/crates/team-agent/src/cli/send/presentation.rs +7 -1
  12. package/crates/team-agent/src/cli/spec.rs +2 -0
  13. package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
  14. package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
  15. package/crates/team-agent/src/cli/tests/base.rs +26 -3
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
  17. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  18. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  19. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  20. package/crates/team-agent/src/compiler.rs +59 -0
  21. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  22. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  23. package/crates/team-agent/src/coordinator/health.rs +173 -0
  24. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  25. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  26. package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
  27. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  29. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  30. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  31. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  32. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  33. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  34. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  35. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  36. package/crates/team-agent/src/coordinator/types.rs +49 -0
  37. package/crates/team-agent/src/db/message_store.rs +39 -5
  38. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  39. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  40. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  41. package/crates/team-agent/src/leader/registry.rs +65 -0
  42. package/crates/team-agent/src/leader/start.rs +920 -63
  43. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  44. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  45. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -12
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -0
  48. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  49. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  50. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
  51. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
  52. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  53. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  54. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  55. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  56. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  57. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
  58. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  59. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  60. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  61. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  63. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  64. package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
  65. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
  66. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  67. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  68. package/crates/team-agent/src/lifecycle/launch.rs +99 -29
  69. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  70. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  71. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  72. package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
  73. package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
  74. package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
  75. package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
  76. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  77. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  78. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  79. package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
  80. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  81. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  82. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  83. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  84. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  85. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  86. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  88. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
  89. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
  90. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  91. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
  92. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  93. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  94. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
  95. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  96. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  97. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  98. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  99. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  100. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  101. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  102. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  103. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
  104. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  105. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  106. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  107. package/crates/team-agent/src/lifecycle/tests.rs +23 -13
  108. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  109. package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
  110. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  111. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  112. package/crates/team-agent/src/messaging/delivery.rs +405 -40
  113. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  114. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  115. package/crates/team-agent/src/messaging/results.rs +4 -0
  116. package/crates/team-agent/src/messaging/send.rs +34 -0
  117. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  118. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  119. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  121. package/crates/team-agent/src/messaging/types.rs +4 -1
  122. package/crates/team-agent/src/model/enums.rs +14 -5
  123. package/crates/team-agent/src/model/permissions.rs +3 -0
  124. package/crates/team-agent/src/os_probe.rs +73 -2
  125. package/crates/team-agent/src/provider/adapter.rs +231 -4
  126. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
  127. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  128. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  129. package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
  130. package/crates/team-agent/src/provider/classify.rs +4 -2
  131. package/crates/team-agent/src/provider/faults.rs +2 -1
  132. package/crates/team-agent/src/provider/mod.rs +11 -0
  133. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  134. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  135. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  136. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  137. package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
  138. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  139. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  140. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  141. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  142. package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
  143. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  144. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  145. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  146. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  147. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  148. package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
  149. package/crates/team-agent/src/provider/types.rs +4 -2
  150. package/crates/team-agent/src/provider/wire.rs +23 -1
  151. package/crates/team-agent/src/state/persist.rs +301 -8
  152. package/crates/team-agent/src/state/repository.rs +5 -0
  153. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  154. package/crates/team-agent/src/tmux_backend.rs +1064 -75
  155. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  156. package/crates/team-agent/src/transport.rs +248 -1
  157. package/npm/install.mjs +129 -73
  158. package/package.json +4 -4
  159. package/skills/team-agent/SKILL.md +33 -238
  160. package/skills/team-agent/command-coverage.json +31 -0
  161. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  162. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  163. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  164. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
@@ -0,0 +1,229 @@
1
+ //! ---
2
+ //! purpose: cursor 席位角色必须显式写 model;写了 effort 必须编译失败
3
+ //! contract:
4
+ //! provides:
5
+ //! - name: cursor-missing-model-refuses
6
+ //! what: 缺 model 的 cursor 角色启动失败(错误含 model)
7
+ //! - name: cursor-explicit-model-reaches-argv
8
+ //! what: 写了 model 则该值进入 `--model <值>`
9
+ //! - name: cursor-effort-refuses-compile
10
+ //! what: 角色写 effort 则失败,不得丢掉后仍起席
11
+ //! - name: cursor-second-seat-refuses
12
+ //! what: 同 workspace 第二 CursorAgent 拒绝,文案含 TEAM_AGENT_ID/mcp.json
13
+ //! boundary:
14
+ //! - 只钉 Provider::CursorAgent;不改 grok/claude 路径
15
+ //! maturity: wired
16
+ //! ---
17
+
18
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
19
+
20
+ #[path = "../../../tests/support/hermetic.rs"]
21
+ mod hermetic_guard;
22
+ #[allow(dead_code)]
23
+ fn _hermetic_boundary_marker(_: &hermetic_guard::HermeticTestEnv) {}
24
+
25
+ use std::path::{Path, PathBuf};
26
+ use std::sync::atomic::{AtomicU64, Ordering};
27
+
28
+ use serial_test::serial;
29
+ use team_agent::lifecycle::quick_start_with_transport_in_workspace;
30
+ use team_agent::transport::test_support::OfflineTransport;
31
+
32
+ #[test]
33
+ #[serial(env)]
34
+ fn cursor_role_missing_model_refuses_to_start() {
35
+ let ws = tmp_dir("cursor-no-model");
36
+ let team = write_role_team(&ws, "cursortm", "cursor_writer", None, None);
37
+ let transport = OfflineTransport::new();
38
+ let result = quick_start_with_transport_in_workspace(
39
+ &ws,
40
+ &team,
41
+ None,
42
+ true,
43
+ Some("cursortm"),
44
+ &transport,
45
+ );
46
+ let err = match result {
47
+ Ok(report) => panic!("cursor role without model must refuse; report={report:?}"),
48
+ Err(error) => error.to_string(),
49
+ };
50
+ let lower = err.to_ascii_lowercase();
51
+ assert!(
52
+ lower.contains("model"),
53
+ "error must name the missing field; err={err}"
54
+ );
55
+ assert!(
56
+ lower.contains("built-in") || lower.contains("builtin") || err.contains("内建"),
57
+ "error must name the built-in default; err={err}"
58
+ );
59
+ assert!(
60
+ transport.spawn_records().is_empty(),
61
+ "refusal must happen before spawn; records={:?}",
62
+ transport.spawn_records()
63
+ );
64
+ }
65
+
66
+ #[test]
67
+ #[serial(env)]
68
+ fn cursor_role_explicit_model_reaches_argv() {
69
+ let ws = tmp_dir("cursor-with-model");
70
+ let team = write_role_team(
71
+ &ws,
72
+ "cursortm",
73
+ "cursor_writer",
74
+ Some("sonnet-4-thinking"),
75
+ None,
76
+ );
77
+ let transport = OfflineTransport::new();
78
+ quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
79
+ .expect("cursor role with explicit model must start");
80
+ let argv = first_spawn_argv(&transport);
81
+ assert!(
82
+ argv_contains_adjacent(&argv, &["--model", "sonnet-4-thinking"]),
83
+ "declared model must appear; argv={argv:?}"
84
+ );
85
+ assert!(
86
+ argv_contains_adjacent(&argv, &["--trust"]),
87
+ "argv must keep --trust; argv={argv:?}"
88
+ );
89
+ }
90
+
91
+ #[test]
92
+ #[serial(env)]
93
+ fn cursor_role_with_effort_refuses_to_start() {
94
+ let ws = tmp_dir("cursor-effort");
95
+ let team = write_role_team(
96
+ &ws,
97
+ "cursortm",
98
+ "cursor_writer",
99
+ Some("sonnet-4-thinking"),
100
+ Some("high"),
101
+ );
102
+ let transport = OfflineTransport::new();
103
+ let result = quick_start_with_transport_in_workspace(
104
+ &ws,
105
+ &team,
106
+ None,
107
+ true,
108
+ Some("cursortm"),
109
+ &transport,
110
+ );
111
+ let err = match result {
112
+ Ok(report) => panic!("cursor role with effort must refuse; report={report:?}"),
113
+ Err(error) => error.to_string(),
114
+ };
115
+ let lower = err.to_ascii_lowercase();
116
+ assert!(
117
+ lower.contains("effort"),
118
+ "error must name effort; err={err}"
119
+ );
120
+ assert!(
121
+ transport.spawn_records().is_empty(),
122
+ "must refuse before spawn; records={:?}",
123
+ transport.spawn_records()
124
+ );
125
+ }
126
+
127
+ #[test]
128
+ #[serial(env)]
129
+ fn cursor_second_seat_in_same_workspace_is_refused() {
130
+ let ws = tmp_dir("cursor-two");
131
+ let team = ws.join("cursortm");
132
+ std::fs::create_dir_all(team.join("agents")).unwrap();
133
+ std::fs::write(
134
+ team.join("TEAM.md"),
135
+ "---\nname: cursortm\nobjective: two cursor seats.\nprovider: cursor_agent\ndangerously_skip_permissions: true\n---\n\nTeam.\n",
136
+ )
137
+ .unwrap();
138
+ for id in ["cursor_a", "cursor_b"] {
139
+ std::fs::write(
140
+ team.join("agents").join(format!("{id}.md")),
141
+ format!(
142
+ "---\nname: {id}\nrole: Cursor Writer\nprovider: cursor_agent\nmodel: sonnet-4-thinking\nauth_mode: subscription\ndangerously_skip_permissions: true\ntools:\n - mcp_team\n---\n\nWorker.\n"
143
+ ),
144
+ )
145
+ .unwrap();
146
+ }
147
+ let transport = OfflineTransport::new();
148
+ let result = quick_start_with_transport_in_workspace(
149
+ &ws,
150
+ &team,
151
+ None,
152
+ true,
153
+ Some("cursortm"),
154
+ &transport,
155
+ );
156
+ let err = match result {
157
+ Ok(report) => panic!("second cursor seat must refuse; report={report:?}"),
158
+ Err(error) => error.to_string(),
159
+ };
160
+ assert!(
161
+ err.contains("TEAM_AGENT_ID") && err.contains("mcp.json"),
162
+ "error must name the last-writer identity file; err={err}"
163
+ );
164
+ assert!(
165
+ transport.spawn_records().is_empty(),
166
+ "must refuse before any cursor spawn; records={:?}",
167
+ transport.spawn_records()
168
+ );
169
+ }
170
+
171
+ fn first_spawn_argv(transport: &OfflineTransport) -> Vec<String> {
172
+ let records = transport.spawn_records();
173
+ assert!(
174
+ !records.is_empty(),
175
+ "expected a worker spawn; records={records:?}"
176
+ );
177
+ records[0].1.clone()
178
+ }
179
+
180
+ fn argv_contains_adjacent(hay: &[String], needle: &[&str]) -> bool {
181
+ hay.windows(needle.len())
182
+ .any(|window| window.iter().map(String::as_str).eq(needle.iter().copied()))
183
+ }
184
+
185
+ fn write_role_team(
186
+ ws: &Path,
187
+ team_key: &str,
188
+ agent_id: &str,
189
+ model: Option<&str>,
190
+ effort: Option<&str>,
191
+ ) -> PathBuf {
192
+ let team = ws.join(team_key);
193
+ std::fs::create_dir_all(team.join("agents")).unwrap();
194
+ std::fs::write(
195
+ team.join("TEAM.md"),
196
+ format!(
197
+ "---\nname: {team_key}\nobjective: cursor model contract.\nprovider: cursor_agent\ndangerously_skip_permissions: true\n---\n\nTeam.\n"
198
+ ),
199
+ )
200
+ .unwrap();
201
+ let model_line = match model {
202
+ Some(value) => format!("model: {value}\n"),
203
+ None => String::new(),
204
+ };
205
+ let effort_line = match effort {
206
+ Some(value) => format!("effort: {value}\n"),
207
+ None => String::new(),
208
+ };
209
+ std::fs::write(
210
+ team.join("agents").join(format!("{agent_id}.md")),
211
+ format!(
212
+ "---\nname: {agent_id}\nrole: Cursor Writer\nprovider: cursor_agent\n{model_line}{effort_line}auth_mode: subscription\ndangerously_skip_permissions: true\ntools:\n - mcp_team\n---\n\nWorker.\n"
213
+ ),
214
+ )
215
+ .unwrap();
216
+ team
217
+ }
218
+
219
+ fn tmp_dir(tag: &str) -> PathBuf {
220
+ static N: AtomicU64 = AtomicU64::new(0);
221
+ let dir = std::env::temp_dir().join(format!(
222
+ "ta-rs-cursor-model-{tag}-{}-{}",
223
+ std::process::id(),
224
+ N.fetch_add(1, Ordering::Relaxed)
225
+ ));
226
+ let _ = std::fs::remove_dir_all(&dir);
227
+ std::fs::create_dir_all(&dir).unwrap();
228
+ std::fs::canonicalize(dir).unwrap()
229
+ }
@@ -0,0 +1,353 @@
1
+ //! ---
2
+ //! purpose: cursor 无假 pending;有 chatId+磁盘 marker 时 restart 走 --resume
3
+ //! contract:
4
+ //! provides:
5
+ //! - name: cursor-fresh-no-fake-session-id
6
+ //! what: fresh spawn 不写 --session-id,_pending 空,session_id 空
7
+ //! - name: cursor-restart-resume-argv
8
+ //! what: session_id + store.db 齐时 restart 发 `--resume <chatId>`
9
+ //! - name: cursor-classify-resume-when-archive-present
10
+ //! what: classify 在 marker 存在时 Resume,缺失时 Refuse 而非静默 fresh
11
+ //! boundary:
12
+ //! - 不改 reset-agent --discard-session
13
+ //! - 不覆盖 grok/claude 三拍
14
+ //! maturity: wired
15
+ //! ---
16
+
17
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
18
+
19
+ #[path = "../../../tests/support/hermetic.rs"]
20
+ mod hermetic_guard;
21
+ #[allow(dead_code)]
22
+ fn _hermetic_boundary_marker(_: &hermetic_guard::HermeticTestEnv) {}
23
+
24
+ use std::path::{Path, PathBuf};
25
+ use std::sync::atomic::{AtomicU64, Ordering};
26
+
27
+ use crate::lifecycle::restart::classify_restart_plan_with_resume_validation;
28
+ use crate::lifecycle::types::ResumeDecision;
29
+ use crate::provider::session_scan::cursor::{
30
+ cursor_session_archive_present, cursor_session_dir,
31
+ };
32
+ use crate::state::persist::{load_runtime_state, save_runtime_state};
33
+ use serial_test::serial;
34
+ use crate::model::ids::AgentId;
35
+ use team_agent::lifecycle::{
36
+ fork_agent_with_transport, quick_start_with_transport_in_workspace, restart_with_transport,
37
+ };
38
+ use team_agent::transport::test_support::OfflineTransport;
39
+
40
+ #[test]
41
+ #[serial(env)]
42
+ fn cursor_fresh_spawn_does_not_invent_session_id() {
43
+ let (ws, _home, _guard, team) = seed_cursor("pending");
44
+ let transport = OfflineTransport::new();
45
+ quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
46
+ .expect("cursor fresh start");
47
+ let argv = first_spawn_argv(&transport);
48
+ assert!(
49
+ argv_flag(&argv, "--session-id").is_none(),
50
+ "cursor must not invent --session-id; argv={argv:?}"
51
+ );
52
+ assert!(
53
+ argv_flag(&argv, "--resume").is_none(),
54
+ "fresh spawn must not --resume; argv={argv:?}"
55
+ );
56
+ assert!(
57
+ !argv.iter().any(|a| a == "--continue"),
58
+ "fresh spawn must not --continue; argv={argv:?}"
59
+ );
60
+ let state = load_runtime_state(&ws).expect("state");
61
+ assert_eq!(
62
+ state
63
+ .pointer("/agents/cursor_writer/_pending_session_id")
64
+ .and_then(|v| v.as_str()),
65
+ None,
66
+ "no fake pending; agent={:?}",
67
+ state.pointer("/agents/cursor_writer")
68
+ );
69
+ assert_eq!(
70
+ state
71
+ .pointer("/agents/cursor_writer/session_id")
72
+ .and_then(|v| v.as_str()),
73
+ None,
74
+ "session_id stays null until capture; agent={:?}",
75
+ state.pointer("/agents/cursor_writer")
76
+ );
77
+ }
78
+
79
+ #[test]
80
+ #[serial(env)]
81
+ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
82
+ let (ws, home, _guard, team) = seed_cursor("resume");
83
+ let first = OfflineTransport::new();
84
+ quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &first)
85
+ .expect("initial cursor start");
86
+ let sid = "502896a1-72ba-4c53-9a86-b2da28780806";
87
+ let dir = {
88
+ let path = home
89
+ .join(".cursor")
90
+ .join("chats")
91
+ .join("00a437742b92089861da7821a62f232a")
92
+ .join(sid);
93
+ std::fs::create_dir_all(&path).unwrap();
94
+ std::fs::write(path.join("store.db"), b"").unwrap();
95
+ path
96
+ };
97
+ assert!(cursor_session_archive_present(&dir));
98
+ assert_eq!(cursor_session_dir(sid).as_deref(), Some(dir.as_path()));
99
+
100
+ let mut state = load_runtime_state(&ws).expect("load");
101
+ patch_cursor_writer(&mut state, sid, &dir, &ws);
102
+ save_runtime_state(&ws, &state).expect("save captured");
103
+
104
+ seed_healthy_coordinator(&ws);
105
+
106
+ let restart = OfflineTransport::new();
107
+ restart_with_transport(&ws, false, Some("cursortm"), &restart)
108
+ .expect("restart without --allow-fresh must resume cursor");
109
+ let argv = first_spawn_argv(&restart);
110
+ assert!(
111
+ argv_contains_adjacent(&argv, &["--resume", sid]),
112
+ "captured cursor must restart with --resume <chatId>; argv={argv:?}"
113
+ );
114
+ assert!(
115
+ argv_flag(&argv, "--session-id").is_none(),
116
+ "resume argv must not mint --session-id; argv={argv:?}"
117
+ );
118
+ assert!(
119
+ !argv.iter().any(|a| a == "--continue"),
120
+ "resume argv must not use --continue; argv={argv:?}"
121
+ );
122
+ }
123
+
124
+ #[test]
125
+ #[serial(env)]
126
+ fn cursor_classify_resumes_when_archive_present_and_refuses_when_missing() {
127
+ let base = tmp_dir("classify");
128
+ let home = tmp_dir("classify-home");
129
+ let ws = base.join("ws");
130
+ std::fs::create_dir_all(&ws).unwrap();
131
+ let _guard = HomeGuard::set(&home);
132
+ let sid = "502896a1-72ba-4c53-9a86-b2da28780806";
133
+ let dir = home.join(".cursor").join("chats").join(sid);
134
+ std::fs::create_dir_all(&dir).unwrap();
135
+ std::fs::write(dir.join("meta.json"), b"{}").unwrap();
136
+
137
+ let present = serde_json::json!({
138
+ "agents": {
139
+ "w1": {
140
+ "provider": "cursor_agent",
141
+ "status": "running",
142
+ "session_id": sid,
143
+ "spawn_cwd": ws.to_string_lossy(),
144
+ "rollout_path": dir.to_string_lossy(),
145
+ }
146
+ }
147
+ });
148
+ let plan = classify_restart_plan_with_resume_validation(Some(&ws), &present, false)
149
+ .expect("classify present");
150
+ assert_eq!(plan.decisions.len(), 1);
151
+ assert_eq!(
152
+ plan.decisions[0].decision,
153
+ ResumeDecision::Resume,
154
+ "archive present must Resume; unresumable={:?}",
155
+ plan.unresumable
156
+ );
157
+
158
+ let missing_sid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
159
+ let missing = serde_json::json!({
160
+ "agents": {
161
+ "w1": {
162
+ "provider": "cursor_agent",
163
+ "status": "running",
164
+ "session_id": missing_sid,
165
+ "spawn_cwd": ws.to_string_lossy(),
166
+ }
167
+ }
168
+ });
169
+ let refuse = classify_restart_plan_with_resume_validation(Some(&ws), &missing, false)
170
+ .expect("classify missing");
171
+ assert_eq!(refuse.decisions.len(), 1);
172
+ assert_eq!(
173
+ refuse.decisions[0].decision,
174
+ ResumeDecision::Refuse,
175
+ "missing cursor archive must Refuse, not silent fresh; got {:?}",
176
+ refuse.decisions[0].decision
177
+ );
178
+ }
179
+
180
+ #[test]
181
+ #[serial(env)]
182
+ fn cursor_fork_agent_refuses_as_unsupported_not_unverified() {
183
+ let (ws, _home, _guard, team) = seed_cursor("fork");
184
+ let transport = OfflineTransport::new();
185
+ quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
186
+ .expect("cursor start");
187
+ let result = fork_agent_with_transport(
188
+ &ws,
189
+ &AgentId::new("cursor_writer"),
190
+ &AgentId::new("cursor_fork"),
191
+ None,
192
+ false,
193
+ Some("cursortm"),
194
+ &transport,
195
+ );
196
+ let err = match result {
197
+ Ok(report) => panic!("cursor fork must refuse; report={report:?}"),
198
+ Err(error) => error.to_string(),
199
+ };
200
+ let lower = err.to_ascii_lowercase();
201
+ assert!(
202
+ lower.contains("does not support") || err.contains("不支持"),
203
+ "fork error must be unsupported; err={err}"
204
+ );
205
+ assert!(
206
+ !lower.contains("unverified") && !err.contains("未验证"),
207
+ "must not call unverified slash fork; err={err}"
208
+ );
209
+ }
210
+
211
+ fn patch_cursor_writer(state: &mut serde_json::Value, sid: &str, dir: &Path, ws: &Path) {
212
+ let mut patched = 0u32;
213
+ let mut patch = |agent: &mut serde_json::Value| {
214
+ if let Some(obj) = agent.as_object_mut() {
215
+ obj.insert("session_id".to_string(), serde_json::json!(sid));
216
+ obj.insert(
217
+ "rollout_path".to_string(),
218
+ serde_json::json!(dir.to_string_lossy()),
219
+ );
220
+ obj.insert(
221
+ "spawn_cwd".to_string(),
222
+ serde_json::json!(ws.to_string_lossy()),
223
+ );
224
+ obj.insert("capture_state".to_string(), serde_json::json!("captured"));
225
+ patched += 1;
226
+ }
227
+ };
228
+ if let Some(agent) = state.pointer_mut("/agents/cursor_writer") {
229
+ patch(agent);
230
+ }
231
+ if let Some(teams) = state.get_mut("teams").and_then(|v| v.as_object_mut()) {
232
+ for team in teams.values_mut() {
233
+ if let Some(agent) = team
234
+ .get_mut("agents")
235
+ .and_then(|v| v.get_mut("cursor_writer"))
236
+ {
237
+ patch(agent);
238
+ }
239
+ }
240
+ }
241
+ assert!(
242
+ patched > 0,
243
+ "cursor_writer missing in state; keys={:?}",
244
+ state.as_object().map(|o| o.keys().collect::<Vec<_>>())
245
+ );
246
+ }
247
+
248
+ /// restart 的就绪门要求 `coordinator_health().ok`,而单测里 `start_coordinator`
249
+ /// spawn 的是 `std::env::current_exe()` —— 也就是 libtest 测试二进制本身,它一启动
250
+ /// 就以 `Unrecognized option: 'workspace'` 退出并变成僵尸,`pid_is_running` 的
251
+ /// `ps stat=` 一看到 `Z` 就判 not running。于是就绪门只能靠「抢在死婴被看到之前
252
+ /// 探到它」这个竞态过关,慢机/高负载上必然超时。这里按本仓既有写法(见
253
+ /// `copilot_provider_red.rs` / `restart_build_before_destroy_0540_contract.rs`)
254
+ /// 预先把 pid 文件与 metadata 指向测试进程自己,让 `start_coordinator` 走
255
+ /// `AlreadyRunning` 早返回,竞态被删掉而不是被调宽。
256
+ fn seed_healthy_coordinator(workspace: &Path) {
257
+ let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
258
+ let pid = crate::coordinator::Pid::new(std::process::id());
259
+ std::fs::create_dir_all(
260
+ crate::coordinator::coordinator_pid_path(&workspace)
261
+ .parent()
262
+ .unwrap(),
263
+ )
264
+ .unwrap();
265
+ crate::coordinator::write_coordinator_metadata(
266
+ &workspace,
267
+ pid,
268
+ crate::coordinator::MetadataSource::Boot,
269
+ )
270
+ .expect("write coordinator metadata");
271
+ std::fs::write(
272
+ crate::coordinator::coordinator_pid_path(&workspace),
273
+ pid.to_string(),
274
+ )
275
+ .expect("write coordinator pid");
276
+ }
277
+
278
+ fn seed_cursor(tag: &str) -> (PathBuf, PathBuf, HomeGuard, PathBuf) {
279
+ let ws = tmp_dir(tag);
280
+ let home = tmp_dir(&format!("{tag}-home"));
281
+ let guard = HomeGuard::set(&home);
282
+ let team = write_cursor_role(&ws);
283
+ (ws, home, guard, team)
284
+ }
285
+
286
+ fn first_spawn_argv(transport: &OfflineTransport) -> Vec<String> {
287
+ let records = transport.spawn_records();
288
+ assert!(
289
+ !records.is_empty(),
290
+ "expected a worker spawn; records={records:?}"
291
+ );
292
+ records[0].1.clone()
293
+ }
294
+
295
+ fn argv_flag(argv: &[String], flag: &str) -> Option<String> {
296
+ argv.windows(2)
297
+ .find(|pair| pair[0] == flag)
298
+ .map(|pair| pair[1].clone())
299
+ }
300
+
301
+ fn argv_contains_adjacent(hay: &[String], needle: &[&str]) -> bool {
302
+ hay.windows(needle.len())
303
+ .any(|window| window.iter().map(String::as_str).eq(needle.iter().copied()))
304
+ }
305
+
306
+ fn write_cursor_role(ws: &Path) -> PathBuf {
307
+ let team = ws.join("cursortm");
308
+ std::fs::create_dir_all(team.join("agents")).unwrap();
309
+ std::fs::write(
310
+ team.join("TEAM.md"),
311
+ "---\nname: cursortm\nobjective: cursor resume.\nprovider: cursor_agent\ndangerously_skip_permissions: true\n---\n\nTeam.\n",
312
+ )
313
+ .unwrap();
314
+ std::fs::write(
315
+ team.join("agents").join("cursor_writer.md"),
316
+ "---\nname: cursor_writer\nrole: Cursor Writer\nprovider: cursor_agent\nmodel: sonnet-4-thinking\nauth_mode: subscription\ndangerously_skip_permissions: true\ntools:\n - mcp_team\n---\n\nWorker.\n",
317
+ )
318
+ .unwrap();
319
+ team
320
+ }
321
+
322
+ struct HomeGuard {
323
+ prev: Option<String>,
324
+ }
325
+
326
+ impl HomeGuard {
327
+ fn set(home: &Path) -> Self {
328
+ let prev = std::env::var("HOME").ok();
329
+ std::env::set_var("HOME", home);
330
+ Self { prev }
331
+ }
332
+ }
333
+
334
+ impl Drop for HomeGuard {
335
+ fn drop(&mut self) {
336
+ match &self.prev {
337
+ Some(value) => std::env::set_var("HOME", value),
338
+ None => std::env::remove_var("HOME"),
339
+ }
340
+ }
341
+ }
342
+
343
+ fn tmp_dir(tag: &str) -> PathBuf {
344
+ static N: AtomicU64 = AtomicU64::new(0);
345
+ let dir = std::env::temp_dir().join(format!(
346
+ "ta-rs-cursor-resume-{tag}-{}-{}",
347
+ std::process::id(),
348
+ N.fetch_add(1, Ordering::Relaxed)
349
+ ));
350
+ let _ = std::fs::remove_dir_all(&dir);
351
+ std::fs::create_dir_all(&dir).unwrap();
352
+ std::fs::canonicalize(dir).unwrap()
353
+ }