@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
@@ -0,0 +1,238 @@
1
+ use super::*;
2
+
3
+ pub(super) struct ForkPostSpawnInput<'a> {
4
+ pub workspace: &'a Path,
5
+ pub transport: &'a dyn Transport,
6
+ pub session_name: &'a SessionName,
7
+ pub window: &'a WindowName,
8
+ pub mcp_config_path: &'a Path,
9
+ pub agent_id: &'a AgentId,
10
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
11
+ pub team_key: &'a str,
12
+ pub spawn: &'a crate::transport::SpawnResult,
13
+ }
14
+
15
+ pub(super) fn ensure_fork_spawn_live(input: ForkPostSpawnInput<'_>) -> Result<(), LifecycleError> {
16
+ let rollback = || {
17
+ rollback_fork_after_spawn(
18
+ input.workspace,
19
+ input.transport,
20
+ input.session_name,
21
+ input.window,
22
+ input.mcp_config_path,
23
+ input.agent_id,
24
+ input.profile_launch,
25
+ input.team_key,
26
+ );
27
+ };
28
+ if !matches!(
29
+ input.transport.liveness(&input.spawn.pane_id),
30
+ Ok(PaneLiveness::Live)
31
+ ) {
32
+ rollback();
33
+ return Err(LifecycleError::RequirementUnmet(format!(
34
+ "fork process is not live after spawn: agent={} pane={}",
35
+ input.agent_id,
36
+ input.spawn.pane_id.as_str()
37
+ )));
38
+ }
39
+ Ok(())
40
+ }
41
+
42
+ pub(super) fn prepare_claude_fork_backing(
43
+ provider: Provider,
44
+ plan: &crate::provider::CommandPlan,
45
+ source_backing: &Path,
46
+ source_session_id: &crate::provider::SessionId,
47
+ ) -> Result<Option<crate::provider::adapters::claude_fork::ClaudeForkMaterialization>, LifecycleError>
48
+ {
49
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
50
+ return Ok(None);
51
+ }
52
+ let target_session_id = plan.expected_session_id.as_ref().ok_or_else(|| {
53
+ LifecycleError::Provider("claude fork plan has no snapshot session id".to_string())
54
+ })?;
55
+ crate::provider::adapters::claude_fork::materialize_claude_fork(
56
+ source_backing,
57
+ source_session_id,
58
+ target_session_id,
59
+ )
60
+ .map(Some)
61
+ .map_err(|error| {
62
+ LifecycleError::Provider(format!(
63
+ "context_fork_unavailable: claude snapshot copy failed before spawn: {error}"
64
+ ))
65
+ })
66
+ }
67
+
68
+ pub(super) struct ForkCoordinatorInput<'a> {
69
+ pub workspace: &'a Path,
70
+ pub team_key: &'a str,
71
+ pub agent_id: &'a AgentId,
72
+ pub transport: &'a dyn Transport,
73
+ pub session_name: &'a SessionName,
74
+ pub window: &'a WindowName,
75
+ pub mcp_config_path: &'a Path,
76
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
77
+ }
78
+
79
+ pub(super) fn start_fork_coordinator(
80
+ input: ForkCoordinatorInput<'_>,
81
+ ) -> Result<bool, LifecycleError> {
82
+ let rollback = || {
83
+ rollback_fork_after_spawn(
84
+ input.workspace,
85
+ input.transport,
86
+ input.session_name,
87
+ input.window,
88
+ input.mcp_config_path,
89
+ input.agent_id,
90
+ input.profile_launch,
91
+ input.team_key,
92
+ );
93
+ };
94
+ if let Err(error) = maybe_fail_fork_after_spawn("start_coordinator") {
95
+ rollback();
96
+ return Err(error);
97
+ }
98
+ crate::coordinator::start_coordinator(&crate::coordinator::WorkspacePath::new(
99
+ input.workspace.to_path_buf(),
100
+ ))
101
+ .map(|report| report.ok)
102
+ .map_err(|error| {
103
+ rollback();
104
+ LifecycleError::StatePersist(error.to_string())
105
+ })
106
+ }
107
+
108
+ pub(super) struct ForkFinalizeInput<'a> {
109
+ pub workspace: &'a Path,
110
+ pub team_key: &'a str,
111
+ pub source_agent_id: &'a AgentId,
112
+ pub agent_id: &'a AgentId,
113
+ pub spec_agent: &'a Value,
114
+ pub safety: &'a DangerousApproval,
115
+ pub plan: &'a crate::provider::CommandPlan,
116
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
117
+ pub spawn: &'a crate::transport::SpawnResult,
118
+ pub profile_dir: &'a Path,
119
+ pub dynamic_role_file: &'a Path,
120
+ pub context_proof: &'a crate::provider::session::ContextForkProof,
121
+ pub spawned_at: &'a str,
122
+ pub spawn_epoch: u64,
123
+ }
124
+
125
+ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), LifecycleError> {
126
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
127
+ workspace: input.workspace,
128
+ operation: "fork-agent-finalize",
129
+ team: Some(input.team_key),
130
+ agent_id: Some(input.agent_id),
131
+ })?;
132
+ let mut next_state = crate::state::selector::resolve_active_team(
133
+ input.workspace,
134
+ Some(input.team_key),
135
+ crate::state::selector::SelectorMode::RequireSpec,
136
+ )
137
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
138
+ .state;
139
+ upsert_forked_agent_state(
140
+ &mut next_state,
141
+ input.source_agent_id,
142
+ input.agent_id,
143
+ input.spec_agent,
144
+ input.safety,
145
+ input.plan,
146
+ input.profile_launch,
147
+ input.spawn,
148
+ input.workspace,
149
+ Some(input.profile_dir),
150
+ input.dynamic_role_file,
151
+ input.context_proof,
152
+ input.spawned_at,
153
+ input.spawn_epoch,
154
+ )?;
155
+ if let Some(agent) = next_state
156
+ .get_mut("agents")
157
+ .and_then(serde_json::Value::as_object_mut)
158
+ .and_then(|agents| agents.get_mut(input.agent_id.as_str()))
159
+ .and_then(serde_json::Value::as_object_mut)
160
+ {
161
+ persist_effective_approval_policy(agent, input.safety);
162
+ }
163
+ maybe_fail_fork_after_spawn("save_runtime_state")?;
164
+ crate::state::repository::StateRepository::new(input.workspace)
165
+ .save(
166
+ crate::state::repository::StateWriteIntent::ForkAgent {
167
+ team_key: input.team_key,
168
+ agent_id: input.agent_id.as_str(),
169
+ },
170
+ &next_state,
171
+ )
172
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
173
+ }
174
+
175
+ pub(super) fn verify_fork_registration(
176
+ workspace: &Path,
177
+ team_key: &str,
178
+ agent_id: &AgentId,
179
+ spawn: &crate::transport::SpawnResult,
180
+ window: &WindowName,
181
+ ) -> Result<(), LifecycleError> {
182
+ let saved = crate::state::projection::select_runtime_state(workspace, Some(team_key))
183
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
184
+ let agent = saved
185
+ .get("agents")
186
+ .and_then(|agents| agents.get(agent_id.as_str()))
187
+ .ok_or_else(|| LifecycleError::StatePersist("canonical team row is missing".to_string()))?;
188
+ if agent.get("pane_id").and_then(serde_json::Value::as_str) != Some(spawn.pane_id.as_str()) {
189
+ return Err(LifecycleError::StatePersist(
190
+ "canonical team pane_id does not match spawned pane".to_string(),
191
+ ));
192
+ }
193
+ if agent.get("window").and_then(serde_json::Value::as_str) != Some(window.as_str()) {
194
+ return Err(LifecycleError::StatePersist(
195
+ "canonical team window does not match spawned window".to_string(),
196
+ ));
197
+ }
198
+ if let Some(pid) = spawn.child_pid {
199
+ if agent.get("pane_pid").and_then(serde_json::Value::as_u64) != Some(u64::from(pid)) {
200
+ return Err(LifecycleError::StatePersist(
201
+ "canonical team pane_pid does not match spawned process".to_string(),
202
+ ));
203
+ }
204
+ }
205
+ Ok(())
206
+ }
207
+
208
+ pub(super) fn rollback_fork_after_spawn(
209
+ workspace: &Path,
210
+ transport: &dyn Transport,
211
+ session_name: &SessionName,
212
+ window: &WindowName,
213
+ mcp_config_path: &Path,
214
+ agent_id: &AgentId,
215
+ profile_launch: &crate::provider::ProviderProfileLaunch,
216
+ team_key: &str,
217
+ ) {
218
+ let _ = transport.kill_window(&Target::SessionWindow {
219
+ session: session_name.clone(),
220
+ window: window.clone(),
221
+ });
222
+ if let Ok(_lock) = acquire_agent_lifecycle_lock(LifecycleLockRequest {
223
+ workspace,
224
+ operation: "fork-agent-rollback",
225
+ team: Some(team_key),
226
+ agent_id: Some(agent_id),
227
+ }) {
228
+ let _ = crate::lifecycle::restart::remove::remove_agent_with_transport_locked(
229
+ workspace,
230
+ agent_id,
231
+ true,
232
+ true,
233
+ Some(team_key),
234
+ transport,
235
+ );
236
+ }
237
+ cleanup_fork_mcp_artifacts(workspace, agent_id, mcp_config_path, profile_launch);
238
+ }
@@ -14,6 +14,61 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
14
14
 
15
15
  use super::*;
16
16
 
17
+ pub(super) fn reserve_forked_agent_state(
18
+ state: &mut serde_json::Value,
19
+ source_agent_id: &AgentId,
20
+ as_agent_id: &AgentId,
21
+ spec_agent: &Value,
22
+ dynamic_role_file: &Path,
23
+ ) -> Result<(), LifecycleError> {
24
+ let agents = state
25
+ .as_object_mut()
26
+ .ok_or_else(|| {
27
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
28
+ })?
29
+ .entry("agents".to_string())
30
+ .or_insert_with(|| serde_json::json!({}));
31
+ let agent_map = agents.as_object_mut().ok_or_else(|| {
32
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
33
+ })?;
34
+ if agent_map.contains_key(as_agent_id.as_str()) {
35
+ return Err(LifecycleError::RequirementUnmet(format!(
36
+ "agent id already exists: {as_agent_id}"
37
+ )));
38
+ }
39
+ crate::lifecycle::restart::remove::clear_agent_retirement_in_state(state, as_agent_id);
40
+ let agents = state
41
+ .get_mut("agents")
42
+ .and_then(serde_json::Value::as_object_mut)
43
+ .ok_or_else(|| {
44
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
45
+ })?;
46
+ let mut entry = serde_json::json!({
47
+ "status": "forking",
48
+ "agent_id": as_agent_id.as_str(),
49
+ "window": as_agent_id.as_str(),
50
+ "forked_from": source_agent_id.as_str(),
51
+ "dynamic_role_file": dynamic_role_file.to_string_lossy().to_string(),
52
+ "role_source_ownership": "managed",
53
+ });
54
+ if let Some(object) = entry.as_object_mut() {
55
+ for key in [
56
+ "provider",
57
+ "auth_mode",
58
+ "model",
59
+ "profile",
60
+ "role",
61
+ "effort",
62
+ ] {
63
+ if let Some(value) = spec_agent.get(key) {
64
+ object.insert(key.to_string(), yaml_value_to_json(value));
65
+ }
66
+ }
67
+ }
68
+ agents.insert(as_agent_id.as_str().to_string(), entry);
69
+ Ok(())
70
+ }
71
+
17
72
  pub(super) fn maybe_fail_fork_after_spawn(step: &str) -> Result<(), LifecycleError> {
18
73
  let Ok(reason) = std::env::var("TEAM_AGENT_TEST_FAIL_FORK_AFTER_SPAWN") else {
19
74
  return Ok(());
@@ -42,6 +97,11 @@ pub(super) fn cleanup_fork_mcp_artifacts(
42
97
  .join(".team/runtime/provider-env")
43
98
  .join(format!("{}.env", agent_id.as_str())),
44
99
  );
100
+ let _ = std::fs::remove_dir_all(
101
+ workspace
102
+ .join(".team/runtime/copilot-instructions")
103
+ .join(agent_id.as_str()),
104
+ );
45
105
  if let Some(config_dir) = profile_launch.claude_config_dir.as_ref() {
46
106
  let _ = std::fs::remove_dir_all(config_dir.parent().unwrap_or(config_dir));
47
107
  }
@@ -202,6 +262,10 @@ pub(super) fn upsert_forked_agent_state(
202
262
  spawn: &crate::transport::SpawnResult,
203
263
  spawn_cwd: &Path,
204
264
  profile_dir: Option<&Path>,
265
+ dynamic_role_file: &Path,
266
+ context_proof: &crate::provider::session::ContextForkProof,
267
+ spawned_at: &str,
268
+ spawn_epoch: u64,
205
269
  ) -> Result<(), LifecycleError> {
206
270
  if !state.is_object() {
207
271
  *state = serde_json::json!({});
@@ -241,6 +305,14 @@ pub(super) fn upsert_forked_agent_state(
241
305
  "forked_from".to_string(),
242
306
  serde_json::json!(source_agent_id.as_str()),
243
307
  );
308
+ entry.insert(
309
+ "dynamic_role_file".to_string(),
310
+ serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
311
+ );
312
+ entry.insert(
313
+ "role_source_ownership".to_string(),
314
+ serde_json::json!("managed"),
315
+ );
244
316
  entry.insert(
245
317
  "spawn_cwd".to_string(),
246
318
  serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
@@ -249,6 +321,8 @@ pub(super) fn upsert_forked_agent_state(
249
321
  "pane_id".to_string(),
250
322
  serde_json::json!(spawn.pane_id.as_str()),
251
323
  );
324
+ entry.insert("spawned_at".to_string(), serde_json::json!(spawned_at));
325
+ entry.insert("spawn_epoch".to_string(), serde_json::json!(spawn_epoch));
252
326
  if let Some(pid) = spawn.child_pid {
253
327
  entry.insert("pane_pid".to_string(), serde_json::json!(pid));
254
328
  }
@@ -274,14 +348,36 @@ pub(super) fn upsert_forked_agent_state(
274
348
  );
275
349
  }
276
350
  }
277
- entry.insert("session_id".to_string(), serde_json::Value::Null);
278
- entry.insert("rollout_path".to_string(), serde_json::Value::Null);
279
- entry.insert("captured_at".to_string(), serde_json::Value::Null);
280
- entry.insert("captured_via".to_string(), serde_json::Value::Null);
351
+ entry.insert(
352
+ "session_id".to_string(),
353
+ serde_json::json!(context_proof.new_session_id.as_str()),
354
+ );
355
+ entry.insert(
356
+ "rollout_path".to_string(),
357
+ serde_json::json!(context_proof.backing_path.to_string_lossy().to_string()),
358
+ );
359
+ entry.insert(
360
+ "captured_at".to_string(),
361
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
362
+ );
363
+ entry.insert(
364
+ "captured_via".to_string(),
365
+ serde_json::json!(context_proof.captured_via),
366
+ );
281
367
  entry.insert(
282
368
  "attribution_confidence".to_string(),
283
- serde_json::Value::Null,
369
+ serde_json::json!(context_proof.attribution_confidence),
284
370
  );
371
+ if let Some(root) = context_proof.managed_backing_root.as_ref() {
372
+ entry.insert(
373
+ "session_backing_root".to_string(),
374
+ serde_json::json!(root.to_string_lossy().to_string()),
375
+ );
376
+ entry.insert(
377
+ "session_backing_ownership".to_string(),
378
+ serde_json::json!("managed"),
379
+ );
380
+ }
285
381
  persist_command_plan_state(&mut entry, plan, profile_launch);
286
382
  agent_map.insert(
287
383
  as_agent_id.as_str().to_string(),
@@ -0,0 +1,170 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::lifecycle::LifecycleError;
4
+ use crate::model::ids::AgentId;
5
+ use crate::model::yaml::{self, Value};
6
+
7
+ use super::set_yaml_map_value;
8
+
9
+ pub(super) struct MaterializedRole {
10
+ path: PathBuf,
11
+ keep: bool,
12
+ }
13
+
14
+ impl MaterializedRole {
15
+ pub(super) fn path(&self) -> &Path {
16
+ &self.path
17
+ }
18
+
19
+ pub(super) fn keep(&mut self) {
20
+ self.keep = true;
21
+ }
22
+ }
23
+
24
+ impl Drop for MaterializedRole {
25
+ fn drop(&mut self) {
26
+ if !self.keep {
27
+ let _ = std::fs::remove_file(&self.path);
28
+ }
29
+ }
30
+ }
31
+
32
+ pub(super) fn materialize_latest_role(
33
+ run_workspace: &Path,
34
+ team_dir: &Path,
35
+ state: &serde_json::Value,
36
+ source_agent_id: &AgentId,
37
+ as_agent_id: &AgentId,
38
+ label: Option<&str>,
39
+ ) -> Result<MaterializedRole, LifecycleError> {
40
+ let source_path = resolve_role_source(run_workspace, team_dir, state, source_agent_id)?;
41
+ let (mut meta, body) = crate::compiler::read_front_matter(&source_path)
42
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
43
+ let declared = meta
44
+ .get("name")
45
+ .and_then(Value::as_str)
46
+ .filter(|name| !name.is_empty())
47
+ .ok_or_else(|| {
48
+ LifecycleError::Compile(format!(
49
+ "source role file does not declare name: {}",
50
+ source_path.display()
51
+ ))
52
+ })?;
53
+ if declared != source_agent_id.as_str() {
54
+ return Err(LifecycleError::Compile(format!(
55
+ "source role file declares name '{}' but source agent is '{}'",
56
+ declared, source_agent_id
57
+ )));
58
+ }
59
+ set_yaml_map_value(
60
+ &mut meta,
61
+ "name",
62
+ Value::Str(as_agent_id.as_str().to_string()),
63
+ )?;
64
+ if let Some(label) = label.filter(|value| !value.is_empty()) {
65
+ set_yaml_map_value(&mut meta, "role", Value::Str(label.to_string()))?;
66
+ }
67
+
68
+ let managed_dir = run_workspace.join(".team").join("dynamic-role-files");
69
+ std::fs::create_dir_all(&managed_dir)
70
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
71
+ let path = managed_dir.join(format!("{}.md", as_agent_id.as_str()));
72
+ if path.exists() {
73
+ return Err(LifecycleError::RequirementUnmet(format!(
74
+ "managed role file already exists: {}",
75
+ path.display()
76
+ )));
77
+ }
78
+ let rendered = format!("---\n{}---\n\n{}", yaml::dumps(&meta), body);
79
+ let temp = path.with_extension(format!("md.tmp-{}", std::process::id()));
80
+ std::fs::write(&temp, rendered.as_bytes())
81
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
82
+ if let Err(error) = std::fs::rename(&temp, &path) {
83
+ let _ = std::fs::remove_file(&temp);
84
+ return Err(LifecycleError::StatePersist(error.to_string()));
85
+ }
86
+ Ok(MaterializedRole { path, keep: false })
87
+ }
88
+
89
+ pub(super) fn clamp_materialized_role_to_leader(
90
+ materialized: &Path,
91
+ spec: &Value,
92
+ ) -> Result<(), LifecycleError> {
93
+ let leader_tools = spec
94
+ .get("leader")
95
+ .and_then(|leader| leader.get("tools"))
96
+ .and_then(Value::as_list)
97
+ .map(|items| {
98
+ items
99
+ .iter()
100
+ .filter_map(Value::as_str)
101
+ .map(str::to_string)
102
+ .collect::<Vec<_>>()
103
+ })
104
+ .unwrap_or_default();
105
+ let (mut meta, body) = crate::compiler::read_front_matter(materialized)
106
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
107
+ let requested = meta
108
+ .get("tools")
109
+ .and_then(Value::as_list)
110
+ .map(|items| {
111
+ items
112
+ .iter()
113
+ .filter_map(Value::as_str)
114
+ .map(str::to_string)
115
+ .collect::<Vec<_>>()
116
+ })
117
+ .unwrap_or_default();
118
+ let ceiling = crate::model::permissions::expand_tool_strings(&leader_tools)
119
+ .into_iter()
120
+ .collect::<std::collections::BTreeSet<_>>();
121
+ let effective = crate::model::permissions::expand_tool_strings(&requested)
122
+ .into_iter()
123
+ .filter(|tool| ceiling.contains(tool))
124
+ .map(Value::Str)
125
+ .collect::<Vec<_>>();
126
+ set_yaml_map_value(&mut meta, "tools", Value::List(effective))?;
127
+ let rendered = format!("---\n{}---\n\n{}", yaml::dumps(&meta), body);
128
+ std::fs::write(materialized, rendered.as_bytes())
129
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
130
+ }
131
+
132
+ fn resolve_role_source(
133
+ run_workspace: &Path,
134
+ team_dir: &Path,
135
+ state: &serde_json::Value,
136
+ source_agent_id: &AgentId,
137
+ ) -> Result<PathBuf, LifecycleError> {
138
+ if let Some(raw) = state
139
+ .get("agents")
140
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
141
+ .and_then(|agent| agent.get("dynamic_role_file"))
142
+ .and_then(serde_json::Value::as_str)
143
+ .filter(|value| !value.is_empty())
144
+ {
145
+ let path = PathBuf::from(raw);
146
+ let resolved = if path.is_absolute() {
147
+ path
148
+ } else {
149
+ run_workspace.join(path)
150
+ };
151
+ if resolved.is_file() {
152
+ return Ok(resolved);
153
+ }
154
+ return Err(LifecycleError::Compile(format!(
155
+ "source dynamic role file not found: {}",
156
+ resolved.display()
157
+ )));
158
+ }
159
+ let path = team_dir
160
+ .join("agents")
161
+ .join(format!("{}.md", source_agent_id.as_str()));
162
+ if path.is_file() {
163
+ Ok(path)
164
+ } else {
165
+ Err(LifecycleError::Compile(format!(
166
+ "source role file not found: {}",
167
+ path.display()
168
+ )))
169
+ }
170
+ }
@@ -18,7 +18,7 @@ pub(super) fn agent_is_paused(agent: &Value) -> bool {
18
18
  matches!(agent.get("paused"), Some(Value::Bool(true)))
19
19
  }
20
20
 
21
- pub(super) fn spawn_timestamp() -> String {
21
+ pub(crate) fn spawn_timestamp() -> String {
22
22
  match std::env::var("TEAM_AGENT_TEST_FIXED_SPAWNED_AT") {
23
23
  Ok(value) => value,
24
24
  Err(_) => chrono::Utc::now()
@@ -234,7 +234,7 @@ pub(super) use worker_env::*;
234
234
  pub(crate) use worker_env::{
235
235
  apply_copilot_instructions_overlay, apply_mcp_auto_approval_env, apply_profile_launch_env,
236
236
  fill_spawn_placeholders, fill_spawn_placeholders_full, inherited_env_with_team_overrides,
237
- persist_command_plan_state,
237
+ persist_command_plan_state, spawn_timestamp,
238
238
  };
239
239
 
240
240
  mod identity;
@@ -285,12 +285,24 @@ pub(crate) use add_agent_state::inject_agent_into_spec;
285
285
  pub(super) use add_agent_state::*;
286
286
 
287
287
  mod fork_agent;
288
+ pub use fork_agent::fork_agent_with_transport;
288
289
  pub(super) use fork_agent::*;
289
- pub use fork_agent::{fork_agent, fork_agent_with_transport};
290
+
291
+ mod fork_entry;
292
+ pub use fork_entry::fork_agent;
290
293
 
291
294
  mod fork_state;
292
295
  pub(super) use fork_state::*;
293
296
 
297
+ mod fork_finalize;
298
+ pub(super) use fork_finalize::*;
299
+
300
+ mod role_source;
301
+ pub(super) use role_source::*;
302
+
303
+ mod clone_agent;
304
+ pub use clone_agent::clone_agent;
305
+
294
306
  mod ownership;
295
307
  pub(super) use ownership::*;
296
308
  pub(crate) use ownership::{ensure_owner_allowed, ensure_owner_allowed_for_state, state_path};
@@ -1149,7 +1149,7 @@ fn mark_agent_started(
1149
1149
  }
1150
1150
  agent.insert(
1151
1151
  "spawned_at".to_string(),
1152
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
1152
+ serde_json::json!(spawn.spawned_at.as_str()),
1153
1153
  );
1154
1154
  agent.insert(
1155
1155
  "spawn_cwd".to_string(),
@@ -2,6 +2,7 @@ use super::*;
2
2
 
3
3
  pub(super) struct SpawnedAgentWindow {
4
4
  pub spawn: crate::transport::SpawnResult,
5
+ pub spawned_at: String,
5
6
  pub plan: crate::provider::CommandPlan,
6
7
  pub profile_launch: crate::provider::ProviderProfileLaunch,
7
8
  pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
@@ -327,6 +328,14 @@ pub(super) fn spawn_agent_window(
327
328
  );
328
329
  crate::lifecycle::launch::apply_profile_launch_env(&mut env, &profile_launch);
329
330
  crate::lifecycle::launch::apply_mcp_auto_approval_env(&mut env, safety);
331
+ if provider == crate::provider::Provider::Copilot {
332
+ crate::lifecycle::launch::apply_copilot_instructions_overlay(
333
+ workspace,
334
+ agent_id.as_str(),
335
+ &system_prompt,
336
+ &mut env,
337
+ )?;
338
+ }
330
339
  // 0.3.28 Step 3: per Python parity, worker spawn cwd is ALWAYS `workspace`.
331
340
  // The persisted-state `agent.spawn_cwd` override is ignored (it was a
332
341
  // Rust-only extension that drifted to `.team/runtime/<team_key>/` after
@@ -359,6 +368,7 @@ pub(super) fn spawn_agent_window(
359
368
  // failure can now be diagnosed from events.jsonl — the recorded
360
369
  // expected_session_id == state._pending_session_id (after
361
370
  // mark_agent_started persists the same plan tuple).
371
+ let spawned_at = crate::lifecycle::launch::spawn_timestamp();
362
372
  {
363
373
  let session_id_in_argv = plan
364
374
  .argv
@@ -385,6 +395,7 @@ pub(super) fn spawn_agent_window(
385
395
  "env_unset": env_unset,
386
396
  "tmux_start_mode": tmux_start_mode_pre_spawn,
387
397
  "spawn_epoch": spawn_epoch,
398
+ "spawned_at": spawned_at.as_str(),
388
399
  "source": "restart",
389
400
  "tmux_endpoint": tmux_endpoint,
390
401
  "tmux_endpoint_source": tmux_endpoint_source.unwrap_or("transport"),
@@ -521,6 +532,7 @@ pub(super) fn spawn_agent_window(
521
532
  }
522
533
  Ok(SpawnedAgentWindow {
523
534
  spawn,
535
+ spawned_at,
524
536
  plan,
525
537
  profile_launch,
526
538
  layout_placement: layout_placement.cloned(),