@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
@@ -446,7 +446,10 @@ fn message_not_silently_stuck_accepted_when_coordinator_dead() {
446
446
  assert!(out.ok, "durable persistence is the send success boundary");
447
447
  assert_eq!(out.status, DeliveryStatus::Blocked);
448
448
  assert_eq!(out.message_status.0, "queued_coordinator_unavailable");
449
- assert!(out.message_id.as_deref().is_some_and(|id| id.starts_with("msg_")));
449
+ assert!(out
450
+ .message_id
451
+ .as_deref()
452
+ .is_some_and(|id| id.starts_with("msg_")));
450
453
  assert_eq!(out.reason, Some(DeliveryRefusal::CoordinatorUnavailable));
451
454
  assert!(
452
455
  out.verification
@@ -2290,6 +2293,53 @@ fn u1_multi_team_send_does_not_backfill_top_level_leader_binding() {
2290
2293
  assert_eq!(owner_team_id.as_deref(), Some("team-b"));
2291
2294
  }
2292
2295
 
2296
+ #[test]
2297
+ fn casefile_leader_send_is_durable_without_entering_leader_funnel() {
2298
+ let ws = tmp_ws("casefile-send");
2299
+ crate::state::persist::save_runtime_state(
2300
+ &ws,
2301
+ &serde_json::json!({
2302
+ "session_name": "team-a",
2303
+ "active_team_key": "team-a",
2304
+ "agents": {}
2305
+ }),
2306
+ )
2307
+ .unwrap();
2308
+ let opts = SendOptions {
2309
+ team: Some(TeamKey::new("team-a")),
2310
+ requires_ack: false,
2311
+ presentation: crate::messaging::presentation::PresentationRequest {
2312
+ sink: crate::messaging::presentation::PresentationSink::Casefile,
2313
+ class: crate::messaging::presentation::PresentationClass::Progress,
2314
+ case_id: Some("case-1".to_string()),
2315
+ },
2316
+ ..SendOptions::default()
2317
+ };
2318
+ let out = send_message(
2319
+ &ws,
2320
+ &MessageTarget::Single("leader".to_string()),
2321
+ "internal progress",
2322
+ &opts,
2323
+ )
2324
+ .unwrap();
2325
+ assert_eq!(out.status, DeliveryStatus::StoredOnly);
2326
+ assert_eq!(out.message_status.0, "stored_only");
2327
+ let store = store_for(&ws);
2328
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
2329
+ let (status, presentation): (String, String) = conn
2330
+ .query_row(
2331
+ "select status, presentation from messages where message_id = ?1",
2332
+ [out.message_id.as_deref().unwrap()],
2333
+ |row| Ok((row.get(0)?, row.get(1)?)),
2334
+ )
2335
+ .unwrap();
2336
+ assert_eq!(status, "stored_only");
2337
+ assert_eq!(
2338
+ serde_json::from_str::<serde_json::Value>(&presentation).unwrap()["effective_sink"],
2339
+ "casefile"
2340
+ );
2341
+ }
2342
+
2293
2343
  // ════════════════════════════════════════════════════════════════════════
2294
2344
  // GROUP V — retry_result_deliveries: re-route notify_failed watchers with
2295
2345
  // dedupe_reason rebind_retry. result_delivery.py:19-35.
@@ -3221,30 +3271,41 @@ fn gate054_status_surfaces_pending_leader_notifications() {
3221
3271
 
3222
3272
  #[test]
3223
3273
  fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
3224
- // Structural grep: the recorded-socket branch of the fallback inject
3225
- // chain must NOT compose the workspace or default tmux backends after
3226
- // the endpoint backend rejects. This is the byte-level fence against
3227
- // silent regression.
3274
+ // Structural grep: the recorded-socket transport set must contain only
3275
+ // the endpoint backend, while workspace/default transports remain in the
3276
+ // no-socket arm. Physical injection must consume the pane returned by the
3277
+ // shared resolver rather than rebuilding a target from state.
3228
3278
  let src = include_str!("../leader_receiver.rs");
3229
- let inject_start = src
3230
- .find("let inject_result: Result<InjectReport, TransportError>")
3231
- .expect("fallback inject chain must remain single-typed");
3232
- let inject_end = src[inject_start..]
3233
- .find("};\n\n match inject_result")
3234
- .map(|off| inject_start + off + 2)
3235
- .expect("fallback inject chain must terminate before match");
3236
- let chain = &src[inject_start..inject_end];
3237
- // The socket-recorded arm is the `Some(socket) => {..}` block. It must
3238
- // NOT contain a workspace or default fallback.
3239
- let some_arm_start = chain.find("Some(socket) => {").expect("Some(socket) arm");
3240
- let some_arm_end = chain[some_arm_start..]
3241
- .find("None => {")
3242
- .map(|off| some_arm_start + off)
3243
- .expect("None arm must follow");
3244
- let some_arm = &chain[some_arm_start..some_arm_end];
3279
+ let transports_start = src
3280
+ .find("let transports: Vec<Box<dyn Transport>>")
3281
+ .expect("fallback transport set must remain explicit");
3282
+ let transports_end = src[transports_start..]
3283
+ .find("let mut resolved")
3284
+ .map(|off| transports_start + off)
3285
+ .expect("transport selection must precede shared resolution");
3286
+ let transports = &src[transports_start..transports_end];
3287
+ let socket_arm_start = transports
3288
+ .find("Some(socket) =>")
3289
+ .expect("recorded-socket arm");
3290
+ let socket_arm_end = transports[socket_arm_start..]
3291
+ .find("None =>")
3292
+ .map(|off| socket_arm_start + off)
3293
+ .expect("no-socket arm must follow");
3294
+ let socket_arm = &transports[socket_arm_start..socket_arm_end];
3295
+ assert!(
3296
+ socket_arm.contains("tmux_endpoint_transport")
3297
+ && !socket_arm.contains("tmux_workspace_transport")
3298
+ && !socket_arm.contains("tmux_default_transport"),
3299
+ "0.5.5 gate054: socket-recorded fallback arm must remain endpoint-only; arm={socket_arm}"
3300
+ );
3301
+ let no_socket_arm = &transports[socket_arm_end..];
3302
+ assert!(
3303
+ no_socket_arm.contains("tmux_workspace_transport")
3304
+ && no_socket_arm.contains("tmux_default_transport"),
3305
+ "0.5.5 gate054: workspace/default transports belong only to the no-socket arm; arm={no_socket_arm}"
3306
+ );
3245
3307
  assert!(
3246
- !some_arm.contains("tmux_workspace_transport")
3247
- && !some_arm.contains("tmux_default_transport"),
3248
- "0.5.5 gate054: socket-recorded fallback arm must not compose workspace/default backends; arm={some_arm}"
3308
+ src.contains("let target = Target::Pane(PaneId::new(channel.pane_id));"),
3309
+ "fallback injection must consume the shared resolver's typed channel"
3249
3310
  );
3250
3311
  }
@@ -20,6 +20,8 @@ use super::helpers::MessageStatusShadow;
20
20
  #[serde(rename_all = "snake_case")]
21
21
  pub enum DeliveryStatus {
22
22
  Delivered,
23
+ /// Durable presentation obligation intentionally did not enter physical injection.
24
+ StoredOnly,
23
25
  Failed,
24
26
  /// busy → 延后不丢 (card §131:不 mark failed,留队列)。
25
27
  Queued,
@@ -195,6 +195,7 @@ fn deliver_primary_watcher(
195
195
  false,
196
196
  None,
197
197
  super::InitialDisposition::Accepted,
198
+ None,
198
199
  )?
199
200
  else {
200
201
  unreachable!("watcher notifications do not accept caller-supplied ids")
@@ -301,14 +301,14 @@ impl ProviderAdapter for BasicProviderAdapter {
301
301
  native_mcp_config: false,
302
302
  writes_global_settings: false,
303
303
  },
304
- // Copilot(C-4-1 cr verdict):resume 走 --resume <sid>;**无 fork** 旗标,
305
- // session-store 不支持 branched continuation caps.fork=false 显式拒。
304
+ // Copilot resume 走 --resume <sid>;store fork 在隔离 COPILOT_HOME
305
+ // 复制并重键 session backing 后以 --resume <new sid> 启动。
306
306
  // native_mcp_config=true(`--additional-mcp-config` 接 inline JSON 或 @file);
307
307
  // writes_global_settings=false(session 走 --session-id 预定 UUID,不污染
308
308
  // ~/.copilot/mcp-config.json,help 原文 "augments config for this session")。
309
309
  Provider::Copilot => ProviderCaps {
310
310
  resume: true,
311
- fork: false,
311
+ fork: true,
312
312
  native_mcp_config: true,
313
313
  writes_global_settings: false,
314
314
  },
@@ -829,10 +829,9 @@ impl ProviderAdapter for BasicProviderAdapter {
829
829
  argv.push("--fork-session".to_string());
830
830
  Ok(argv)
831
831
  }
832
- // C-4-2 cr verdict: copilot fork 旗标 + session-store 不支持 branched
833
- // continuation 显式 CapabilityUnsupported,**绝不** silent fallback
834
- // restart-from-scratch(MUST-NOT-13 诚实)。本分支理论上不可达(caps.fork=false
835
- // 已在 fork_with_context 入口拦截,line 582),保留作 totality 守护。
832
+ // Context-free adapter calls cannot materialize the Copilot store fork.
833
+ // The lifecycle path uses `fork_plan`, which receives the prepared isolated
834
+ // backing; never degrade this context-free call to a fresh spawn.
836
835
  Provider::Copilot => Err(ProviderError::CapabilityUnsupported(
837
836
  "copilot CLI 无 fork 旗标,session-store 不支持 branched continuation".to_string(),
838
837
  )),
@@ -858,11 +857,11 @@ impl ProviderAdapter for BasicProviderAdapter {
858
857
  provider_wire(self.provider)
859
858
  )));
860
859
  }
861
- let Some(session_id) = session_id else {
860
+ if session_id.is_none() {
862
861
  return Err(ProviderError::ResumeUnavailable(
863
862
  "fork requires session_id".to_string(),
864
863
  ));
865
- };
864
+ }
866
865
  let expected = next_session_token();
867
866
  let managed = ctx
868
867
  .profile_launch
@@ -881,11 +880,8 @@ impl ProviderAdapter for BasicProviderAdapter {
881
880
  managed,
882
881
  ctx.effort,
883
882
  )?;
884
- argv.push("--session-id".to_string());
885
- argv.push(expected.clone());
886
883
  argv.push("--resume".to_string());
887
- argv.push(session_id.as_str().to_string());
888
- argv.push("--fork-session".to_string());
884
+ argv.push(expected.clone());
889
885
  Ok(CommandPlan {
890
886
  argv,
891
887
  expected_session_id: Some(SessionId::new(expected)),
@@ -918,6 +914,51 @@ impl ProviderAdapter for BasicProviderAdapter {
918
914
  argv.push(session_id.as_str().to_string());
919
915
  Ok(CommandPlan::argv_only(argv))
920
916
  }
917
+ Provider::Copilot => {
918
+ if ctx.auth_mode == AuthMode::CompatibleApi {
919
+ return Err(ProviderError::CapabilityUnsupported(
920
+ "copilot does not support session fork in compatible_api mode".to_string(),
921
+ ));
922
+ }
923
+ let profile = ctx.profile_launch.ok_or_else(|| {
924
+ ProviderError::Command(
925
+ "copilot fork requires a prepared provider profile".to_string(),
926
+ )
927
+ })?;
928
+ let expected = profile
929
+ .env_overlay
930
+ .get("TEAM_AGENT_INTERNAL_COPILOT_FORK_SESSION_ID")
931
+ .filter(|value| !value.is_empty())
932
+ .map(SessionId::new)
933
+ .ok_or_else(|| {
934
+ ProviderError::Command(
935
+ "copilot fork session backing was not materialized".to_string(),
936
+ )
937
+ })?;
938
+ let root = profile
939
+ .env_overlay
940
+ .get("COPILOT_HOME")
941
+ .filter(|value| !value.is_empty())
942
+ .map(PathBuf::from)
943
+ .ok_or_else(|| {
944
+ ProviderError::Command("copilot fork root was not materialized".to_string())
945
+ })?;
946
+ let mut argv = copilot_base_command_resume(
947
+ ctx.auth_mode,
948
+ ctx.mcp_config,
949
+ ctx.system_prompt,
950
+ ctx.model,
951
+ ctx.tools,
952
+ );
953
+ argv.push("--resume".to_string());
954
+ argv.push(expected.as_str().to_string());
955
+ Ok(CommandPlan {
956
+ argv,
957
+ expected_session_id: Some(expected),
958
+ provider_projects_root: Some(root),
959
+ managed_mcp_config: profile.managed_mcp_config,
960
+ })
961
+ }
921
962
  _ => self
922
963
  .fork_with_context(
923
964
  session_id,
@@ -0,0 +1,122 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::provider::{ProviderError, SessionId};
4
+
5
+ #[derive(Debug)]
6
+ pub(crate) struct ClaudeForkMaterialization {
7
+ path: PathBuf,
8
+ keep: bool,
9
+ }
10
+
11
+ impl ClaudeForkMaterialization {
12
+ pub(crate) fn path(&self) -> &Path {
13
+ &self.path
14
+ }
15
+
16
+ pub(crate) fn keep(&mut self) {
17
+ self.keep = true;
18
+ }
19
+ }
20
+
21
+ impl Drop for ClaudeForkMaterialization {
22
+ fn drop(&mut self) {
23
+ if !self.keep {
24
+ let _ = std::fs::remove_file(&self.path);
25
+ }
26
+ }
27
+ }
28
+
29
+ pub(crate) fn materialize_claude_fork(
30
+ source_path: &Path,
31
+ source_session_id: &SessionId,
32
+ target_session_id: &SessionId,
33
+ ) -> Result<ClaudeForkMaterialization, ProviderError> {
34
+ let parent = source_path.parent().ok_or_else(|| {
35
+ ProviderError::Io(format!(
36
+ "claude source backing has no parent: {}",
37
+ source_path.display()
38
+ ))
39
+ })?;
40
+ let target_path = parent.join(format!("{}.jsonl", target_session_id.as_str()));
41
+ if target_path.exists() {
42
+ return Err(ProviderError::Io(format!(
43
+ "claude fork destination already exists: {}",
44
+ target_path.display()
45
+ )));
46
+ }
47
+ let source = std::fs::read_to_string(source_path)
48
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
49
+ let rewritten = source.replace(source_session_id.as_str(), target_session_id.as_str());
50
+ validate_jsonl(&rewritten)?;
51
+ let temp = parent.join(format!(
52
+ ".{}.tmp-{}",
53
+ target_session_id.as_str(),
54
+ std::process::id()
55
+ ));
56
+ let result = std::fs::write(&temp, rewritten)
57
+ .and_then(|_| std::fs::rename(&temp, &target_path))
58
+ .map_err(|error| ProviderError::Io(error.to_string()));
59
+ if let Err(error) = result {
60
+ let _ = std::fs::remove_file(&temp);
61
+ return Err(error);
62
+ }
63
+ Ok(ClaudeForkMaterialization {
64
+ path: target_path,
65
+ keep: false,
66
+ })
67
+ }
68
+
69
+ fn validate_jsonl(text: &str) -> Result<(), ProviderError> {
70
+ let mut rows = 0_usize;
71
+ for (index, line) in text.lines().enumerate() {
72
+ if line.trim().is_empty() {
73
+ continue;
74
+ }
75
+ serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
76
+ ProviderError::Io(format!(
77
+ "claude fork source has invalid JSONL at line {}: {error}",
78
+ index + 1
79
+ ))
80
+ })?;
81
+ rows += 1;
82
+ }
83
+ if rows == 0 {
84
+ return Err(ProviderError::Io(
85
+ "claude fork source backing has no JSONL records".to_string(),
86
+ ));
87
+ }
88
+ Ok(())
89
+ }
90
+
91
+ #[cfg(test)]
92
+ mod tests {
93
+ use super::*;
94
+
95
+ #[test]
96
+ fn snapshot_rekeys_every_record_and_preserves_source() {
97
+ let dir = std::env::temp_dir().join(format!("ta-claude-fork-copy-{}", std::process::id()));
98
+ let _ = std::fs::remove_dir_all(&dir);
99
+ std::fs::create_dir_all(&dir).unwrap();
100
+ let source_id = SessionId::new("11111111-1111-4111-8111-111111111111");
101
+ let target_id = SessionId::new("22222222-2222-4222-8222-222222222222");
102
+ let source_path = dir.join(format!("{}.jsonl", source_id.as_str()));
103
+ let source = format!(
104
+ "{{\"sessionId\":\"{}\"}}\n{{\"payload\":{{\"session_id\":\"{}\"}}}}\n",
105
+ source_id.as_str(),
106
+ source_id.as_str()
107
+ );
108
+ std::fs::write(&source_path, &source).unwrap();
109
+
110
+ let mut materialized =
111
+ materialize_claude_fork(&source_path, &source_id, &target_id).unwrap();
112
+ let target_path = dir.join(format!("{}.jsonl", target_id.as_str()));
113
+ let target = std::fs::read_to_string(&target_path).unwrap();
114
+ assert!(!target.contains(source_id.as_str()));
115
+ assert!(target.contains(target_id.as_str()));
116
+ assert_eq!(std::fs::read_to_string(&source_path).unwrap(), source);
117
+ materialized.keep();
118
+ drop(materialized);
119
+ assert!(target_path.is_file());
120
+ let _ = std::fs::remove_dir_all(&dir);
121
+ }
122
+ }
@@ -0,0 +1,306 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use rusqlite::{params, Connection};
4
+
5
+ use crate::provider::{next_session_token, ProviderError, SessionId};
6
+
7
+ const SESSION_TABLES: [&str; 6] = [
8
+ "turns",
9
+ "checkpoints",
10
+ "session_files",
11
+ "session_refs",
12
+ "forge_trajectory_events",
13
+ "search_index",
14
+ ];
15
+
16
+ #[derive(Debug, Clone)]
17
+ pub(crate) struct CopilotForkMaterialization {
18
+ session_id: SessionId,
19
+ home: PathBuf,
20
+ keep: bool,
21
+ }
22
+
23
+ impl CopilotForkMaterialization {
24
+ pub(crate) fn session_id(&self) -> &SessionId {
25
+ &self.session_id
26
+ }
27
+
28
+ pub(crate) fn home(&self) -> &Path {
29
+ &self.home
30
+ }
31
+
32
+ pub(crate) fn keep(&mut self) {
33
+ self.keep = true;
34
+ }
35
+ }
36
+
37
+ impl Drop for CopilotForkMaterialization {
38
+ fn drop(&mut self) {
39
+ if !self.keep {
40
+ let _ = std::fs::remove_dir_all(&self.home);
41
+ }
42
+ }
43
+ }
44
+
45
+ pub(crate) fn copilot_home() -> Result<PathBuf, ProviderError> {
46
+ if let Some(home) = std::env::var_os("COPILOT_HOME").filter(|value| !value.is_empty()) {
47
+ return Ok(PathBuf::from(home));
48
+ }
49
+ std::env::var_os("HOME")
50
+ .map(PathBuf::from)
51
+ .map(|home| home.join(".copilot"))
52
+ .ok_or_else(|| ProviderError::Io("HOME is unset; cannot resolve COPILOT_HOME".to_string()))
53
+ }
54
+
55
+ pub(crate) fn materialize_copilot_fork(
56
+ workspace: &Path,
57
+ agent_id: &str,
58
+ source_session_id: &SessionId,
59
+ ) -> Result<CopilotForkMaterialization, ProviderError> {
60
+ let source_home = copilot_home()?;
61
+ let source_dir = source_home
62
+ .join("session-state")
63
+ .join(source_session_id.as_str());
64
+ let source_db = source_home.join("session-store.db");
65
+ if !source_dir.is_dir() || !source_db.is_file() {
66
+ return Err(ProviderError::Io(format!(
67
+ "copilot source backing is incomplete under {}",
68
+ source_home.display()
69
+ )));
70
+ }
71
+
72
+ let session_id = SessionId::new(next_session_token());
73
+ let parent = workspace
74
+ .join(".team/runtime/provider-session-forks/copilot")
75
+ .join(agent_id);
76
+ let home = parent.join(session_id.as_str());
77
+ let temp = parent.join(format!(
78
+ ".{}.tmp-{}",
79
+ session_id.as_str(),
80
+ std::process::id()
81
+ ));
82
+ if home.exists() || temp.exists() {
83
+ return Err(ProviderError::Io(format!(
84
+ "copilot fork destination already exists: {}",
85
+ home.display()
86
+ )));
87
+ }
88
+ std::fs::create_dir_all(temp.join("session-state"))
89
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
90
+
91
+ let result = (|| {
92
+ let target_session_dir = temp.join("session-state").join(session_id.as_str());
93
+ copy_tree(&source_dir, &target_session_dir)?;
94
+ rewrite_text_tree(
95
+ &target_session_dir,
96
+ source_session_id.as_str(),
97
+ session_id.as_str(),
98
+ )?;
99
+ rewrite_per_session_db(
100
+ &target_session_dir.join("session.db"),
101
+ source_session_id.as_str(),
102
+ session_id.as_str(),
103
+ )?;
104
+ clone_and_rekey_store(
105
+ &source_db,
106
+ &temp.join("session-store.db"),
107
+ source_session_id.as_str(),
108
+ session_id.as_str(),
109
+ )?;
110
+ std::fs::rename(&temp, &home).map_err(|error| ProviderError::Io(error.to_string()))
111
+ })();
112
+ if let Err(error) = result {
113
+ let _ = std::fs::remove_dir_all(&temp);
114
+ return Err(error);
115
+ }
116
+ Ok(CopilotForkMaterialization {
117
+ session_id,
118
+ home,
119
+ keep: false,
120
+ })
121
+ }
122
+
123
+ fn copy_tree(source: &Path, target: &Path) -> Result<(), ProviderError> {
124
+ std::fs::create_dir_all(target).map_err(|error| ProviderError::Io(error.to_string()))?;
125
+ for entry in std::fs::read_dir(source).map_err(|error| ProviderError::Io(error.to_string()))? {
126
+ let entry = entry.map_err(|error| ProviderError::Io(error.to_string()))?;
127
+ let source_path = entry.path();
128
+ let target_path = target.join(entry.file_name());
129
+ if source_path.is_dir() {
130
+ copy_tree(&source_path, &target_path)?;
131
+ } else {
132
+ std::fs::copy(&source_path, &target_path)
133
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
134
+ }
135
+ }
136
+ Ok(())
137
+ }
138
+
139
+ fn rewrite_text_tree(root: &Path, source: &str, target: &str) -> Result<(), ProviderError> {
140
+ for entry in std::fs::read_dir(root).map_err(|error| ProviderError::Io(error.to_string()))? {
141
+ let entry = entry.map_err(|error| ProviderError::Io(error.to_string()))?;
142
+ let path = entry.path();
143
+ if path.is_dir() {
144
+ rewrite_text_tree(&path, source, target)?;
145
+ continue;
146
+ }
147
+ if path.extension().and_then(|ext| ext.to_str()) == Some("db") {
148
+ continue;
149
+ }
150
+ let Ok(text) = std::fs::read_to_string(&path) else {
151
+ continue;
152
+ };
153
+ if text.contains(source) {
154
+ std::fs::write(&path, text.replace(source, target))
155
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
156
+ }
157
+ }
158
+ Ok(())
159
+ }
160
+
161
+ fn clone_and_rekey_store(
162
+ source_db: &Path,
163
+ target_db: &Path,
164
+ source: &str,
165
+ target: &str,
166
+ ) -> Result<(), ProviderError> {
167
+ let source_conn = Connection::open(source_db).map_err(sql_error)?;
168
+ source_conn
169
+ .execute("VACUUM INTO ?1", [target_db.to_string_lossy().as_ref()])
170
+ .map_err(sql_error)?;
171
+ drop(source_conn);
172
+
173
+ let mut conn = Connection::open(target_db).map_err(sql_error)?;
174
+ conn.pragma_update(None, "foreign_keys", "OFF")
175
+ .map_err(sql_error)?;
176
+ let tx = conn.transaction().map_err(sql_error)?;
177
+ let session_rows = tx
178
+ .execute(
179
+ "update sessions set id = ?1 where id = ?2",
180
+ params![target, source],
181
+ )
182
+ .map_err(sql_error)?;
183
+ if session_rows != 1 {
184
+ return Err(ProviderError::Io(format!(
185
+ "copilot session-store source row count must be 1, got {session_rows}"
186
+ )));
187
+ }
188
+ for table in SESSION_TABLES {
189
+ require_session_column(&tx, table)?;
190
+ tx.execute(
191
+ &format!("update {table} set session_id = ?1 where session_id = ?2"),
192
+ params![target, source],
193
+ )
194
+ .map_err(sql_error)?;
195
+ }
196
+ tx.commit().map_err(sql_error)?;
197
+ verify_rekeyed_store(&conn, source, target)
198
+ }
199
+
200
+ fn require_session_column(conn: &Connection, table: &str) -> Result<(), ProviderError> {
201
+ let mut stmt = conn
202
+ .prepare(&format!("pragma table_info({table})"))
203
+ .map_err(sql_error)?;
204
+ let mut rows = stmt.query([]).map_err(sql_error)?;
205
+ while let Some(row) = rows.next().map_err(sql_error)? {
206
+ let name: String = row.get(1).map_err(sql_error)?;
207
+ if name == "session_id" {
208
+ return Ok(());
209
+ }
210
+ }
211
+ Err(ProviderError::Io(format!(
212
+ "copilot session-store table {table} is missing session_id"
213
+ )))
214
+ }
215
+
216
+ fn verify_rekeyed_store(
217
+ conn: &Connection,
218
+ source: &str,
219
+ target: &str,
220
+ ) -> Result<(), ProviderError> {
221
+ let target_sessions: i64 = conn
222
+ .query_row(
223
+ "select count(*) from sessions where id = ?1",
224
+ [target],
225
+ |row| row.get(0),
226
+ )
227
+ .map_err(sql_error)?;
228
+ let source_sessions: i64 = conn
229
+ .query_row(
230
+ "select count(*) from sessions where id = ?1",
231
+ [source],
232
+ |row| row.get(0),
233
+ )
234
+ .map_err(sql_error)?;
235
+ if target_sessions != 1 || source_sessions != 0 {
236
+ return Err(ProviderError::Io(
237
+ "copilot session-store session id rekey verification failed".to_string(),
238
+ ));
239
+ }
240
+ for table in SESSION_TABLES {
241
+ let remaining: i64 = conn
242
+ .query_row(
243
+ &format!("select count(*) from {table} where session_id = ?1"),
244
+ [source],
245
+ |row| row.get(0),
246
+ )
247
+ .map_err(sql_error)?;
248
+ if remaining != 0 {
249
+ return Err(ProviderError::Io(format!(
250
+ "copilot session-store table {table} still references source session"
251
+ )));
252
+ }
253
+ }
254
+ Ok(())
255
+ }
256
+
257
+ fn rewrite_per_session_db(path: &Path, source: &str, target: &str) -> Result<(), ProviderError> {
258
+ if !path.is_file() {
259
+ return Ok(());
260
+ }
261
+ let mut conn = Connection::open(path).map_err(sql_error)?;
262
+ let columns: Vec<(String, String)> = {
263
+ let mut tables = conn
264
+ .prepare("select name from sqlite_master where type = 'table'")
265
+ .map_err(sql_error)?;
266
+ let names = tables
267
+ .query_map([], |row| row.get::<_, String>(0))
268
+ .map_err(sql_error)?
269
+ .collect::<Result<Vec<_>, _>>()
270
+ .map_err(sql_error)?;
271
+ let mut columns = Vec::new();
272
+ for table in names {
273
+ let mut stmt = conn
274
+ .prepare(&format!(
275
+ "pragma table_info('{}')",
276
+ table.replace('\'', "''")
277
+ ))
278
+ .map_err(sql_error)?;
279
+ let rows = stmt
280
+ .query_map([], |row| row.get::<_, String>(1))
281
+ .map_err(sql_error)?;
282
+ for column in rows {
283
+ let column = column.map_err(sql_error)?;
284
+ if column == "session_id" || column == "recipient_session_id" {
285
+ columns.push((table.clone(), column));
286
+ }
287
+ }
288
+ }
289
+ columns
290
+ };
291
+ let tx = conn.transaction().map_err(sql_error)?;
292
+ for (table, column) in columns {
293
+ let table = table.replace('"', "\"\"");
294
+ let column = column.replace('"', "\"\"");
295
+ tx.execute(
296
+ &format!("update \"{table}\" set \"{column}\" = ?1 where \"{column}\" = ?2"),
297
+ params![target, source],
298
+ )
299
+ .map_err(sql_error)?;
300
+ }
301
+ tx.commit().map_err(sql_error)
302
+ }
303
+
304
+ fn sql_error(error: rusqlite::Error) -> ProviderError {
305
+ ProviderError::Io(format!("copilot sqlite: {error}"))
306
+ }