@team-agent/installer 0.5.52 → 0.5.54

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 (87) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +111 -3
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/send.rs +1 -0
  9. package/crates/team-agent/src/cli/spec.rs +4 -1
  10. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +12 -0
  15. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  16. package/crates/team-agent/src/db/message_store.rs +31 -2
  17. package/crates/team-agent/src/db/migration.rs +7 -6
  18. package/crates/team-agent/src/db/schema.rs +18 -5
  19. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  20. package/crates/team-agent/src/kill_audit.rs +67 -0
  21. package/crates/team-agent/src/leader/lease.rs +324 -57
  22. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  24. package/crates/team-agent/src/leader/start.rs +144 -23
  25. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  26. package/crates/team-agent/src/leader/types.rs +6 -0
  27. package/crates/team-agent/src/lib.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  31. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  32. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  33. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  34. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  35. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  36. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  37. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  42. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  43. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  44. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  46. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  47. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  48. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  51. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  52. package/crates/team-agent/src/mcp_server/tools.rs +89 -4
  53. package/crates/team-agent/src/mcp_server/types.rs +7 -0
  54. package/crates/team-agent/src/mcp_server/wire.rs +65 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  56. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  57. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  58. package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
  59. package/crates/team-agent/src/messaging/mod.rs +2 -1
  60. package/crates/team-agent/src/messaging/persist.rs +68 -2
  61. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  62. package/crates/team-agent/src/messaging/results.rs +130 -3
  63. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  64. package/crates/team-agent/src/messaging/send.rs +54 -2
  65. package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
  66. package/crates/team-agent/src/messaging/types.rs +2 -0
  67. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  68. package/crates/team-agent/src/provider/adapter.rs +54 -13
  69. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  70. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  71. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  72. package/crates/team-agent/src/provider/session/capture.rs +395 -52
  73. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  74. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  75. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  76. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  77. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  78. package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
  79. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  80. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  81. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  82. package/crates/team-agent/src/provider/tests.rs +1 -0
  83. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  84. package/crates/team-agent/src/tmux_backend.rs +29 -2
  85. package/package.json +4 -4
  86. package/schemas/result-envelope.schema.json +10 -0
  87. package/skills/team-agent/SKILL.md +11 -3
@@ -1,55 +1,16 @@
1
- use std::collections::{BTreeMap, BTreeSet};
2
- use std::path::{Path, PathBuf};
3
- use std::process::Command;
4
-
1
+ use super::*;
2
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
5
3
  use crate::lifecycle::profile_launch::parse_provider;
6
4
  use crate::lifecycle::*;
7
- use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
5
+ use crate::model::enums::{AuthMode, DisplayBackend, Provider, ProviderEffort};
8
6
  use crate::model::ids::AgentId;
9
7
  use crate::model::permissions::{self, AgentPermissionInput};
10
8
  use crate::model::yaml::{self, Value};
11
9
  use crate::state::persist::load_runtime_state;
12
10
  use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
13
-
14
- use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
15
-
16
- use super::*;
17
-
18
- /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
19
- /// native session fork(provider 须 supports_session_fork ∧ auth_mode!=compatible_api);
20
- /// 失败回滚,每条失败臂 `adapter.cleanup_mcp`。
21
- pub fn fork_agent(
22
- workspace: &Path,
23
- source_agent_id: &AgentId,
24
- as_agent_id: &AgentId,
25
- label: Option<&str>,
26
- open_display: bool,
27
- team: Option<&str>,
28
- ) -> Result<ForkAgentReport, LifecycleError> {
29
- let selected = crate::state::selector::resolve_active_team(
30
- workspace,
31
- team,
32
- crate::state::selector::SelectorMode::RequireSpec,
33
- )
34
- .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
35
- // **0.3.24 add-agent socket drift fix** (same root cause): fork-agent must
36
- // also route to the live team's persisted tmux endpoint, not the workspace-
37
- // hash for_workspace socket. Same orphan-on-wrong-socket pathology.
38
- let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
39
- &selected.run_workspace,
40
- Some(selected.team_key.as_str()),
41
- )
42
- .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace));
43
- fork_agent_with_transport(
44
- workspace,
45
- source_agent_id,
46
- as_agent_id,
47
- label,
48
- open_display,
49
- team,
50
- &transport,
51
- )
52
- }
11
+ use std::collections::{BTreeMap, BTreeSet};
12
+ use std::path::{Path, PathBuf};
13
+ use std::process::Command;
53
14
 
54
15
  pub fn fork_agent_with_transport(
55
16
  workspace: &Path,
@@ -104,12 +65,8 @@ pub fn fork_agent_with_transport(
104
65
  let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
105
66
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
106
67
  })?;
107
- // 0.4.6 tuple-atomic contract (audit §Fork 修改清单, line 201): fork
108
- // must require the COMPLETE source tuple (session_id + rollout_path +
109
- // captured_at + captured_via) before treating the scalar session_id
110
- // as resumable truth. A row carrying only `session_id` is a partial
111
- // tuple (pre-0.4.6 bug source), and the native fork would attach to
112
- // a session that has no confirmed backing.
68
+ // Fork requires the complete source tuple before treating session_id as
69
+ // resumable truth; a scalar-only row has no confirmed backing.
113
70
  let source_agent_state = state
114
71
  .get("agents")
115
72
  .and_then(|v| v.get(source_agent_id.as_str()))
@@ -142,7 +99,24 @@ pub fn fork_agent_with_transport(
142
99
  (session_id+rollout_path+captured_at+captured_via required)"
143
100
  )));
144
101
  }
145
- let session_id = crate::provider::SessionId::new(session_id_str.unwrap().to_string());
102
+ let Some(source_backing_raw) = rollout_path_str else {
103
+ return Err(LifecycleError::Provider(format!(
104
+ "cannot fork {source_agent_id}: source session backing is missing"
105
+ )));
106
+ };
107
+ let source_backing = Path::new(source_backing_raw);
108
+ if !source_backing.is_file() {
109
+ return Err(LifecycleError::Provider(format!(
110
+ "cannot fork {source_agent_id}: source session backing is not readable: {}",
111
+ source_backing.display()
112
+ )));
113
+ }
114
+ let Some(source_session_id) = session_id_str else {
115
+ return Err(LifecycleError::Provider(format!(
116
+ "cannot fork {source_agent_id}: source session id is missing"
117
+ )));
118
+ };
119
+ let session_id = crate::provider::SessionId::new(source_session_id.to_string());
146
120
  let session_name = state
147
121
  .get("session_name")
148
122
  .and_then(|v| v.as_str())
@@ -160,7 +134,24 @@ pub fn fork_agent_with_transport(
160
134
  as_agent_id.as_str()
161
135
  )));
162
136
  }
163
- let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id, label)?;
137
+ let mut materialized_role = materialize_latest_role(
138
+ &workspace,
139
+ &fork_team_dir,
140
+ &state,
141
+ source_agent_id,
142
+ as_agent_id,
143
+ label,
144
+ )?;
145
+ clamp_materialized_role_to_leader(materialized_role.path(), &spec)?;
146
+ let team_meta = crate::compiler::read_front_matter(&fork_team_dir.join("TEAM.md"))
147
+ .map(|(meta, _)| meta)
148
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
149
+ let workspace_s = workspace.to_string_lossy().to_string();
150
+ let compiled =
151
+ crate::compiler::compile_role_agent(materialized_role.path(), &team_meta, &workspace_s)
152
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
153
+ let new_spec =
154
+ append_forked_agent(&spec, &compiled.agent, source_agent_id, as_agent_id, label)?;
164
155
  // validate 用角色定义目录的 team_workspace(校验 working_directory),非 spec 落点。
165
156
  let validate_ws =
166
157
  crate::model::paths::team_workspace(&fork_team_dir).unwrap_or_else(|_| workspace.clone());
@@ -223,14 +214,43 @@ pub fn fork_agent_with_transport(
223
214
  })?;
224
215
  // E5 §3:profiles 随角色定义目录(team_dir),不随已迁出的 spec。
225
216
  let profile_dir = fork_team_dir.join("profiles");
226
- let profile_launch =
227
- crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
217
+ let mut profile_launch =
218
+ match crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
228
219
  &workspace,
229
220
  as_agent_id.as_str(),
230
221
  new_agent,
231
222
  Some(&profile_dir),
232
223
  Some(&mcp_config),
233
- )?;
224
+ ) {
225
+ Ok(profile_launch) => profile_launch,
226
+ Err(error) => {
227
+ let _ = std::fs::write(&spec_path, text.as_bytes());
228
+ let _ = std::fs::remove_file(&mcp_config_path);
229
+ return Err(error);
230
+ }
231
+ };
232
+ let mut copilot_fork = None;
233
+ if provider == Provider::Copilot {
234
+ let materialized = crate::provider::adapters::copilot_fork::materialize_copilot_fork(
235
+ &workspace,
236
+ as_agent_id.as_str(),
237
+ &session_id,
238
+ )
239
+ .map_err(|error| {
240
+ let _ = std::fs::write(&spec_path, text.as_bytes());
241
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
242
+ LifecycleError::Provider(error.to_string())
243
+ })?;
244
+ profile_launch.env_overlay.insert(
245
+ "COPILOT_HOME".to_string(),
246
+ materialized.home().to_string_lossy().to_string(),
247
+ );
248
+ profile_launch.env_overlay.insert(
249
+ "TEAM_AGENT_INTERNAL_COPILOT_FORK_SESSION_ID".to_string(),
250
+ materialized.session_id().as_str().to_string(),
251
+ );
252
+ copilot_fork = Some(materialized);
253
+ }
234
254
  let command_model = profile_launch.command_overrides.model.as_deref().or(model);
235
255
  // 0.4.x provider effort MVP: fork inherits effort from the new agent JSON
236
256
  // (compiler.rs propagated the role/team effort into the agent at fork-spawn).
@@ -257,8 +277,12 @@ pub fn fork_agent_with_transport(
257
277
  )
258
278
  .map_err(|e| {
259
279
  let _ = std::fs::write(&spec_path, text.as_bytes());
280
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
260
281
  LifecycleError::Provider(e.to_string())
261
282
  })?;
283
+ profile_launch
284
+ .env_overlay
285
+ .remove("TEAM_AGENT_INTERNAL_COPILOT_FORK_SESSION_ID");
262
286
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
263
287
  point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
264
288
  }
@@ -268,15 +292,54 @@ pub fn fork_agent_with_transport(
268
292
  as_agent_id.as_str(),
269
293
  Some(&fork_team),
270
294
  );
295
+ if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
296
+ plan.provider_projects_root = source_backing.parent().map(Path::to_path_buf);
297
+ }
271
298
  let window = WindowName::new(as_agent_id.as_str());
272
- // fork inherits the parent agent's owner team via runtime state (`active_team_key`).
299
+ let backing_before = crate::provider::session::ContextBackingSnapshot::capture(provider, &plan);
300
+ let mut claude_fork = prepare_claude_fork_backing(provider, &plan, source_backing, &session_id)
301
+ .map_err(|error| {
302
+ let _ = std::fs::write(&spec_path, text.as_bytes());
303
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
304
+ error
305
+ })?;
273
306
  let mut env =
274
307
  inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
275
308
  apply_profile_launch_env(&mut env, &profile_launch);
276
309
  apply_mcp_auto_approval_env(&mut env, &safety);
277
- // golden operations.py:336 -> _tmux_start_command_for_agent_window (runtime.py:1017-1020): branch on
278
- // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
279
- // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
310
+ if provider == Provider::Copilot {
311
+ apply_copilot_instructions_overlay(
312
+ &workspace,
313
+ as_agent_id.as_str(),
314
+ &system_prompt,
315
+ &mut env,
316
+ )
317
+ .map_err(|error| {
318
+ let _ = std::fs::write(&spec_path, text.as_bytes());
319
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
320
+ error
321
+ })?;
322
+ }
323
+ let mut reserved_state = state.clone();
324
+ reserve_forked_agent_state(
325
+ &mut reserved_state,
326
+ source_agent_id,
327
+ as_agent_id,
328
+ new_agent,
329
+ materialized_role.path(),
330
+ )?;
331
+ if let Err(error) = crate::state::repository::StateRepository::new(&workspace).save(
332
+ crate::state::repository::StateWriteIntent::ForkAgent {
333
+ team_key: &fork_team,
334
+ agent_id: as_agent_id.as_str(),
335
+ },
336
+ &reserved_state,
337
+ ) {
338
+ let _ = std::fs::write(&spec_path, text.as_bytes());
339
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
340
+ return Err(LifecycleError::StatePersist(error.to_string()));
341
+ }
342
+ // Match launch/restart: absent session spawns first; otherwise add a window.
280
343
  let session_live = transport.has_session(&session_name).unwrap_or(false);
281
344
  let env_unset = crate::layout::worker_env::isolate_worker_spawn_env(
282
345
  provider,
@@ -286,6 +349,10 @@ pub fn fork_agent_with_transport(
286
349
  provider,
287
350
  ),
288
351
  );
352
+ // Release the metadata lock before per-seat provider convergence; finalize reacquires it.
353
+ drop(_lock);
354
+ let spawn_epoch = 1_u64;
355
+ let spawned_at = spawn_timestamp_for_agent(u32::try_from(spawn_epoch).unwrap_or(u32::MAX));
289
356
  let spawn_result = if session_live {
290
357
  transport.spawn_into_with_env_unset(
291
358
  &session_name,
@@ -305,104 +372,79 @@ pub fn fork_agent_with_transport(
305
372
  &env_unset,
306
373
  )
307
374
  };
308
- let spawn = spawn_result.map_err(|e| {
309
- let _ = std::fs::write(&spec_path, text.as_bytes());
310
- LifecycleError::Transport(e.to_string())
375
+ let spawn = match spawn_result {
376
+ Ok(spawn) => spawn,
377
+ Err(error) => {
378
+ rollback_fork_after_spawn(
379
+ &workspace,
380
+ transport,
381
+ &session_name,
382
+ &window,
383
+ &mcp_config_path,
384
+ as_agent_id,
385
+ &profile_launch,
386
+ &fork_team,
387
+ );
388
+ return Err(LifecycleError::Transport(error.to_string()));
389
+ }
390
+ };
391
+ ensure_fork_spawn_live(ForkPostSpawnInput {
392
+ workspace: &workspace,
393
+ transport,
394
+ session_name: &session_name,
395
+ window: &window,
396
+ mcp_config_path: &mcp_config_path,
397
+ agent_id: as_agent_id,
398
+ profile_launch: &profile_launch,
399
+ team_key: &fork_team,
400
+ spawn: &spawn,
311
401
  })?;
312
- let old_state = state.clone();
313
- let mut next_state = state;
314
- upsert_forked_agent_state(
315
- &mut next_state,
316
- source_agent_id,
317
- as_agent_id,
318
- new_agent,
319
- &safety,
402
+ let convergence_deadline =
403
+ crate::provider::session::context_fork_convergence_deadline(provider);
404
+ let context_proof = match crate::provider::session::verify_context_fork(
405
+ provider,
406
+ &session_id,
320
407
  &plan,
321
- &profile_launch,
322
- &spawn,
408
+ &backing_before,
409
+ claude_fork.as_ref().map(|materialized| materialized.path()),
410
+ as_agent_id.as_str(),
323
411
  &workspace,
324
- Some(&profile_dir),
325
- )?;
326
- if let Some(agent) = next_state
327
- .get_mut("agents")
328
- .and_then(serde_json::Value::as_object_mut)
329
- .and_then(|agents| agents.get_mut(as_agent_id.as_str()))
330
- .and_then(serde_json::Value::as_object_mut)
331
- {
332
- persist_effective_approval_policy(agent, &safety);
333
- }
334
- if let Err(e) = maybe_fail_fork_after_spawn("save_runtime_state") {
335
- rollback_fork_after_spawn(
336
- &workspace,
337
- &spec_path,
338
- &text,
339
- &old_state,
340
- transport,
341
- &session_name,
342
- &window,
343
- &mcp_config_path,
344
- as_agent_id,
345
- &profile_launch,
346
- &fork_team,
347
- );
348
- return Err(e);
349
- }
350
- if let Err(e) = crate::state::repository::StateRepository::new(&workspace).save(
351
- crate::state::repository::StateWriteIntent::ForkAgent {
352
- team_key: &fork_team,
353
- agent_id: as_agent_id.as_str(),
354
- },
355
- &next_state,
412
+ &spawned_at,
413
+ convergence_deadline,
356
414
  ) {
415
+ Ok(proof) => proof,
416
+ Err(error) => {
417
+ rollback_fork_after_spawn(
418
+ &workspace,
419
+ transport,
420
+ &session_name,
421
+ &window,
422
+ &mcp_config_path,
423
+ as_agent_id,
424
+ &profile_launch,
425
+ &fork_team,
426
+ );
427
+ return Err(LifecycleError::Provider(error.to_string()));
428
+ }
429
+ };
430
+ if let Err(error) = finalize_fork_state(ForkFinalizeInput {
431
+ workspace: &workspace,
432
+ team_key: &fork_team,
433
+ source_agent_id,
434
+ agent_id: as_agent_id,
435
+ spec_agent: new_agent,
436
+ safety: &safety,
437
+ plan: &plan,
438
+ profile_launch: &profile_launch,
439
+ spawn: &spawn,
440
+ profile_dir: &profile_dir,
441
+ dynamic_role_file: materialized_role.path(),
442
+ context_proof: &context_proof,
443
+ spawned_at: &spawned_at,
444
+ spawn_epoch,
445
+ }) {
357
446
  rollback_fork_after_spawn(
358
447
  &workspace,
359
- &spec_path,
360
- &text,
361
- &old_state,
362
- transport,
363
- &session_name,
364
- &window,
365
- &mcp_config_path,
366
- as_agent_id,
367
- &profile_launch,
368
- &fork_team,
369
- );
370
- return Err(LifecycleError::StatePersist(e.to_string()));
371
- }
372
- let registration =
373
- crate::state::projection::select_runtime_state(&workspace, Some(fork_team.as_str()))
374
- .map_err(|e| e.to_string())
375
- .and_then(|saved| {
376
- let agent = saved
377
- .get("agents")
378
- .and_then(|agents| agents.get(as_agent_id.as_str()))
379
- .ok_or_else(|| "canonical team row is missing".to_string())?;
380
- if agent.get("pane_id").and_then(serde_json::Value::as_str)
381
- != Some(spawn.pane_id.as_str())
382
- {
383
- return Err("canonical team pane_id does not match spawned pane".to_string());
384
- }
385
- if agent.get("window").and_then(serde_json::Value::as_str) != Some(window.as_str())
386
- {
387
- return Err("canonical team window does not match spawned window".to_string());
388
- }
389
- if let Some(pid) = spawn.child_pid {
390
- if agent.get("pane_pid").and_then(serde_json::Value::as_u64)
391
- != Some(u64::from(pid))
392
- {
393
- return Err(
394
- "canonical team pane_pid does not match spawned process".to_string()
395
- );
396
- }
397
- }
398
- Ok(())
399
- });
400
- if let Err(reason) = registration {
401
- rollback_fork_after_spawn(
402
- &workspace,
403
- &spec_path,
404
- &text,
405
- &old_state,
406
448
  transport,
407
449
  &session_name,
408
450
  &window,
@@ -411,16 +453,13 @@ pub fn fork_agent_with_transport(
411
453
  &profile_launch,
412
454
  &fork_team,
413
455
  );
414
- return Err(LifecycleError::StatePersist(format!(
415
- "fork spawned but team registration readback failed: {reason}"
416
- )));
456
+ return Err(error);
417
457
  }
418
- if let Err(e) = maybe_fail_fork_after_spawn("start_coordinator") {
458
+ if let Err(error) =
459
+ verify_fork_registration(&workspace, &fork_team, as_agent_id, &spawn, &window)
460
+ {
419
461
  rollback_fork_after_spawn(
420
462
  &workspace,
421
- &spec_path,
422
- &text,
423
- &old_state,
424
463
  transport,
425
464
  &session_name,
426
465
  &window,
@@ -429,28 +468,25 @@ pub fn fork_agent_with_transport(
429
468
  &profile_launch,
430
469
  &fork_team,
431
470
  );
432
- return Err(e);
471
+ return Err(error);
433
472
  }
434
- let coordinator_started = crate::coordinator::start_coordinator(
435
- &crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
436
- )
437
- .map(|report| report.ok)
438
- .map_err(|e| {
439
- rollback_fork_after_spawn(
440
- &workspace,
441
- &spec_path,
442
- &text,
443
- &old_state,
444
- transport,
445
- &session_name,
446
- &window,
447
- &mcp_config_path,
448
- as_agent_id,
449
- &profile_launch,
450
- &fork_team,
451
- );
452
- LifecycleError::StatePersist(e.to_string())
473
+ let coordinator_started = start_fork_coordinator(ForkCoordinatorInput {
474
+ workspace: &workspace,
475
+ team_key: &fork_team,
476
+ agent_id: as_agent_id,
477
+ transport,
478
+ session_name: &session_name,
479
+ window: &window,
480
+ mcp_config_path: &mcp_config_path,
481
+ profile_launch: &profile_launch,
453
482
  })?;
483
+ materialized_role.keep();
484
+ if let Some(materialized) = claude_fork.as_mut() {
485
+ materialized.keep();
486
+ }
487
+ if let Some(materialized) = copilot_fork.as_mut() {
488
+ materialized.keep();
489
+ }
454
490
  Ok(ForkAgentReport {
455
491
  source_agent_id: source_agent_id.clone(),
456
492
  new_agent_id: as_agent_id.clone(),
@@ -459,34 +495,6 @@ pub fn fork_agent_with_transport(
459
495
  state_file: crate::state::persist::runtime_state_path(&workspace),
460
496
  coordinator_started,
461
497
  },
462
- session_id: None,
498
+ session_id: Some(context_proof.new_session_id),
463
499
  })
464
500
  }
465
-
466
- pub(super) fn rollback_fork_after_spawn(
467
- workspace: &Path,
468
- spec_path: &Path,
469
- spec_text: &str,
470
- old_state: &serde_json::Value,
471
- transport: &dyn Transport,
472
- session_name: &SessionName,
473
- window: &WindowName,
474
- mcp_config_path: &Path,
475
- agent_id: &AgentId,
476
- profile_launch: &crate::provider::ProviderProfileLaunch,
477
- team_key: &str,
478
- ) {
479
- let _ = transport.kill_window(&Target::SessionWindow {
480
- session: session_name.clone(),
481
- window: window.clone(),
482
- });
483
- let _ = std::fs::write(spec_path, spec_text.as_bytes());
484
- let _ = crate::state::repository::StateRepository::new(workspace).save(
485
- crate::state::repository::StateWriteIntent::AgentRollback {
486
- team_key: Some(team_key),
487
- agent_id: agent_id.as_str(),
488
- },
489
- old_state,
490
- );
491
- cleanup_fork_mcp_artifacts(workspace, agent_id, mcp_config_path, profile_launch);
492
- }
@@ -0,0 +1,36 @@
1
+ use super::*;
2
+
3
+ /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
4
+ /// native session fork(provider 须 supports_session_fork ∧ auth_mode!=compatible_api);
5
+ /// 失败回滚,每条失败臂 `adapter.cleanup_mcp`。
6
+ pub fn fork_agent(
7
+ workspace: &Path,
8
+ source_agent_id: &AgentId,
9
+ as_agent_id: &AgentId,
10
+ label: Option<&str>,
11
+ open_display: bool,
12
+ team: Option<&str>,
13
+ ) -> Result<ForkAgentReport, LifecycleError> {
14
+ let selected = crate::state::selector::resolve_active_team(
15
+ workspace,
16
+ team,
17
+ crate::state::selector::SelectorMode::RequireSpec,
18
+ )
19
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
20
+ // Fork-agent routes to the selected live team's persisted endpoint, not
21
+ // the workspace-hash fallback socket.
22
+ let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
23
+ &selected.run_workspace,
24
+ Some(selected.team_key.as_str()),
25
+ )
26
+ .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace));
27
+ fork_agent_with_transport(
28
+ workspace,
29
+ source_agent_id,
30
+ as_agent_id,
31
+ label,
32
+ open_display,
33
+ team,
34
+ &transport,
35
+ )
36
+ }