@team-agent/installer 0.5.52 → 0.5.53
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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +18 -2
- package/crates/team-agent/src/cli/emit.rs +17 -0
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +18 -4
- package/crates/team-agent/src/mcp_server/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/wire.rs +30 -7
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
- package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +321 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +8 -3
|
@@ -1,37 +1,47 @@
|
|
|
1
|
-
|
|
1
|
+
pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
|
|
2
|
+
chrono::DateTime::parse_from_rfc3339(raw)
|
|
3
|
+
.ok()
|
|
4
|
+
.map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
|
|
5
|
+
}
|
|
2
6
|
|
|
3
|
-
pub(super) fn
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
pub(super) fn rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
|
8
|
+
created_at_from_rollout_uuid(path)
|
|
9
|
+
.or_else(|| created_at_from_rollout_head(path))
|
|
10
|
+
.or_else(|| created_at_from_rollout_filename(path))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
pub(super) fn retain_spawn_cwd(
|
|
14
|
+
context: &super::CaptureSessionContext,
|
|
15
|
+
out: &mut Vec<super::CapturedSessionCandidate>,
|
|
6
16
|
) {
|
|
7
|
-
let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) else {
|
|
8
|
-
return;
|
|
9
|
-
};
|
|
10
|
-
let grace = std::time::Duration::from_secs(5);
|
|
11
|
-
let cutoff = spawned_at.checked_sub(grace).unwrap_or(spawned_at);
|
|
12
17
|
out.retain(|candidate| {
|
|
13
|
-
let path =
|
|
14
|
-
|
|
15
|
-
None => return false,
|
|
18
|
+
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
19
|
+
return false;
|
|
16
20
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
let Ok(text) =
|
|
22
|
+
super::common::read_head_text(path.as_path(), super::common::CAPTURE_HEAD_BYTES)
|
|
23
|
+
else {
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
super::common::parse_session_records(&text)
|
|
27
|
+
.iter()
|
|
28
|
+
.filter_map(super::common::record_cwd)
|
|
29
|
+
.any(|cwd| {
|
|
30
|
+
super::common::paths_equivalent(std::path::Path::new(&cwd), &context.spawn_cwd)
|
|
31
|
+
})
|
|
24
32
|
});
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
fn created_at_from_rollout_uuid(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
|
36
|
+
let name = path.file_name()?.to_str()?;
|
|
37
|
+
let stem = name.strip_suffix(".jsonl")?;
|
|
38
|
+
let uuid = stem.get(stem.len().checked_sub(36)?..)?;
|
|
39
|
+
if uuid.as_bytes().get(14).copied() != Some(b'7') {
|
|
40
|
+
return None;
|
|
41
|
+
}
|
|
42
|
+
let millis =
|
|
43
|
+
u64::from_str_radix(&format!("{}{}", uuid.get(..8)?, uuid.get(9..13)?), 16).ok()?;
|
|
44
|
+
std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(millis))
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
|
@@ -77,6 +87,10 @@ fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time:
|
|
|
77
87
|
#[cfg(test)]
|
|
78
88
|
mod tests {
|
|
79
89
|
use super::*;
|
|
90
|
+
use crate::provider::session_scan::{common, CaptureSessionContext, CapturedSessionCandidate};
|
|
91
|
+
use crate::provider::Provider;
|
|
92
|
+
use crate::provider::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
|
|
93
|
+
use std::path::PathBuf;
|
|
80
94
|
|
|
81
95
|
#[test]
|
|
82
96
|
fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
|
|
@@ -84,4 +98,107 @@ mod tests {
|
|
|
84
98
|
assert!(parse_spawned_at("not-a-date").is_none());
|
|
85
99
|
assert!(parse_spawned_at("").is_none());
|
|
86
100
|
}
|
|
101
|
+
|
|
102
|
+
fn candidate(path: PathBuf, session_id: &str) -> CapturedSessionCandidate {
|
|
103
|
+
CapturedSessionCandidate {
|
|
104
|
+
captured: CapturedSession {
|
|
105
|
+
session_id: Some(SessionId::new(session_id)),
|
|
106
|
+
rollout_path: Some(RolloutPath::new(path)),
|
|
107
|
+
captured_via: CaptureVia::FsWatch,
|
|
108
|
+
attribution_confidence: Confidence::High,
|
|
109
|
+
spawn_cwd: PathBuf::from("/tmp"),
|
|
110
|
+
},
|
|
111
|
+
embedded_agent_id: None,
|
|
112
|
+
positive_agent_id_match: false,
|
|
113
|
+
agent_path_match: false,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn spawned_at_filter_rejects_prior_round_and_accepts_current_rollout() {
|
|
119
|
+
let dir =
|
|
120
|
+
std::env::temp_dir().join(format!("ta-codex-spawn-filter-{}", std::process::id()));
|
|
121
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
122
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
123
|
+
let stale =
|
|
124
|
+
dir.join("rollout-2026-07-21T22-45-40-11111111-1111-4111-8111-111111111111.jsonl");
|
|
125
|
+
let fresh =
|
|
126
|
+
dir.join("rollout-2026-07-21T23-20-01-22222222-2222-4222-8222-222222222222.jsonl");
|
|
127
|
+
std::fs::write(&stale, "{}\n").unwrap();
|
|
128
|
+
std::fs::write(&fresh, "{}\n").unwrap();
|
|
129
|
+
let context = CaptureSessionContext {
|
|
130
|
+
agent_id: "worker".to_string(),
|
|
131
|
+
spawn_cwd: dir.clone(),
|
|
132
|
+
pane_id: None,
|
|
133
|
+
pane_pid: None,
|
|
134
|
+
spawned_at: Some("2026-07-21T23:20:00+00:00".to_string()),
|
|
135
|
+
expected_session_id: None,
|
|
136
|
+
provider_projects_root: None,
|
|
137
|
+
};
|
|
138
|
+
let mut out = vec![
|
|
139
|
+
candidate(stale, "11111111-1111-4111-8111-111111111111"),
|
|
140
|
+
candidate(fresh.clone(), "22222222-2222-4222-8222-222222222222"),
|
|
141
|
+
];
|
|
142
|
+
common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
|
|
143
|
+
assert_eq!(out.len(), 1);
|
|
144
|
+
assert_eq!(
|
|
145
|
+
out[0].captured.rollout_path.as_ref().unwrap().as_path(),
|
|
146
|
+
fresh
|
|
147
|
+
);
|
|
148
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[test]
|
|
152
|
+
fn spawned_at_filter_fails_closed_with_an_invalid_boundary() {
|
|
153
|
+
let mut out = vec![candidate(PathBuf::from("/tmp/old.jsonl"), "old")];
|
|
154
|
+
let mut context = CaptureSessionContext {
|
|
155
|
+
agent_id: "worker".to_string(),
|
|
156
|
+
spawn_cwd: PathBuf::from("/tmp"),
|
|
157
|
+
pane_id: None,
|
|
158
|
+
pane_pid: None,
|
|
159
|
+
spawned_at: None,
|
|
160
|
+
expected_session_id: None,
|
|
161
|
+
provider_projects_root: None,
|
|
162
|
+
};
|
|
163
|
+
common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
|
|
164
|
+
assert_eq!(out.len(), 1, "legacy direct scan has no cohort boundary");
|
|
165
|
+
|
|
166
|
+
context.spawned_at = Some("not-a-date".to_string());
|
|
167
|
+
common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
|
|
168
|
+
assert!(out.is_empty());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[test]
|
|
172
|
+
fn unique_window_uses_created_at_not_active_sibling_mtime() {
|
|
173
|
+
let dir =
|
|
174
|
+
std::env::temp_dir().join(format!("ta-codex-created-window-{}", std::process::id()));
|
|
175
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
176
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
177
|
+
let stale =
|
|
178
|
+
dir.join("rollout-2026-07-22T03-12-13-11111111-1111-4111-8111-111111111111.jsonl");
|
|
179
|
+
let fresh =
|
|
180
|
+
dir.join("rollout-2026-07-22T03-15-30-22222222-2222-4222-8222-222222222222.jsonl");
|
|
181
|
+
std::fs::write(&stale, "{}\n").unwrap();
|
|
182
|
+
std::fs::write(&fresh, "{}\n").unwrap();
|
|
183
|
+
let context = CaptureSessionContext {
|
|
184
|
+
agent_id: "clone".to_string(),
|
|
185
|
+
spawn_cwd: dir.clone(),
|
|
186
|
+
pane_id: None,
|
|
187
|
+
pane_pid: None,
|
|
188
|
+
spawned_at: Some("2026-07-22T03:15:29+00:00".to_string()),
|
|
189
|
+
expected_session_id: None,
|
|
190
|
+
provider_projects_root: None,
|
|
191
|
+
};
|
|
192
|
+
let mut out = vec![
|
|
193
|
+
candidate(stale, "11111111-1111-4111-8111-111111111111"),
|
|
194
|
+
candidate(fresh.clone(), "22222222-2222-4222-8222-222222222222"),
|
|
195
|
+
];
|
|
196
|
+
common::apply_spawn_time_window_if_unique(Provider::Codex, &context, &mut out);
|
|
197
|
+
assert_eq!(out.len(), 1);
|
|
198
|
+
assert_eq!(
|
|
199
|
+
out[0].captured.rollout_path.as_ref().unwrap().as_path(),
|
|
200
|
+
fresh
|
|
201
|
+
);
|
|
202
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
203
|
+
}
|
|
87
204
|
}
|
|
@@ -129,7 +129,41 @@ pub(super) fn parse_candidate_files(
|
|
|
129
129
|
out
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/// Reject provider files older than the persisted process-cohort boundary.
|
|
133
|
+
pub(super) fn apply_spawned_at_filter(
|
|
134
|
+
provider: Provider,
|
|
135
|
+
context: &CaptureSessionContext,
|
|
136
|
+
out: &mut Vec<CapturedSessionCandidate>,
|
|
137
|
+
) {
|
|
138
|
+
let Some(raw_spawned_at) = context.spawned_at.as_deref() else {
|
|
139
|
+
// Legacy direct capture_session_id callers have no cohort boundary;
|
|
140
|
+
// the canonical runtime capture path rejects such rows before scan.
|
|
141
|
+
return;
|
|
142
|
+
};
|
|
143
|
+
let Some(spawned_at) = super::codex::parse_spawned_at(raw_spawned_at) else {
|
|
144
|
+
out.clear();
|
|
145
|
+
return;
|
|
146
|
+
};
|
|
147
|
+
out.retain(|candidate| {
|
|
148
|
+
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
if matches!(provider, Provider::Codex) {
|
|
152
|
+
return super::codex::rollout_created_at(path.as_path())
|
|
153
|
+
.is_some_and(|created_at| created_at >= spawned_at);
|
|
154
|
+
}
|
|
155
|
+
candidate_mtime(path.as_path()).is_some_and(|mtime| mtime >= spawned_at)
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn candidate_mtime(path: &Path) -> Option<std::time::SystemTime> {
|
|
160
|
+
std::fs::metadata(path)
|
|
161
|
+
.and_then(|meta| meta.modified())
|
|
162
|
+
.ok()
|
|
163
|
+
}
|
|
164
|
+
|
|
132
165
|
pub(super) fn apply_spawn_time_window_if_unique(
|
|
166
|
+
provider: Provider,
|
|
133
167
|
context: &CaptureSessionContext,
|
|
134
168
|
out: &mut Vec<CapturedSessionCandidate>,
|
|
135
169
|
) {
|
|
@@ -150,12 +184,11 @@ pub(super) fn apply_spawn_time_window_if_unique(
|
|
|
150
184
|
.captured
|
|
151
185
|
.rollout_path
|
|
152
186
|
.as_ref()
|
|
153
|
-
.and_then(|p| {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.ok()
|
|
187
|
+
.and_then(|p| match provider {
|
|
188
|
+
Provider::Codex => super::codex::rollout_created_at(p.as_path()),
|
|
189
|
+
_ => candidate_mtime(p.as_path()),
|
|
157
190
|
})
|
|
158
|
-
.is_some_and(|
|
|
191
|
+
.is_some_and(|created_at| created_at >= spawned_at)
|
|
159
192
|
})
|
|
160
193
|
.cloned()
|
|
161
194
|
.collect();
|
|
@@ -366,7 +399,7 @@ pub(super) fn record_cwd(record: &serde_json::Value) -> Option<String> {
|
|
|
366
399
|
.map(ToString::to_string)
|
|
367
400
|
}
|
|
368
401
|
|
|
369
|
-
fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
402
|
+
pub(super) fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
370
403
|
if left == right {
|
|
371
404
|
return true;
|
|
372
405
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
use std::path::
|
|
1
|
+
use std::path::Path;
|
|
2
2
|
|
|
3
3
|
use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
|
|
4
4
|
|
|
5
5
|
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
6
6
|
|
|
7
7
|
pub(super) fn scan_session_store(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
|
|
8
|
-
let
|
|
8
|
+
let Ok(home) = crate::provider::adapters::copilot_fork::copilot_home() else {
|
|
9
9
|
return Vec::new();
|
|
10
10
|
};
|
|
11
|
-
let db_path = home.join("
|
|
11
|
+
let db_path = home.join("session-store.db");
|
|
12
12
|
if !db_path.exists() {
|
|
13
13
|
return Vec::new();
|
|
14
14
|
}
|
|
@@ -59,6 +59,7 @@ mod tests {
|
|
|
59
59
|
#![allow(clippy::unwrap_used)]
|
|
60
60
|
|
|
61
61
|
use super::*;
|
|
62
|
+
use std::path::PathBuf;
|
|
62
63
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
63
64
|
|
|
64
65
|
struct HomeGuard {
|
|
@@ -175,6 +176,49 @@ mod tests {
|
|
|
175
176
|
let _ = std::fs::remove_dir_all(&base);
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
#[test]
|
|
180
|
+
#[serial_test::serial(env)]
|
|
181
|
+
fn copilot_scanner_respects_copilot_home() {
|
|
182
|
+
let base = tmp_root("custom-home");
|
|
183
|
+
let copilot_home = base.join("isolated-copilot");
|
|
184
|
+
std::fs::create_dir_all(&copilot_home).unwrap();
|
|
185
|
+
let worker_id = "1142c4c2-0000-4000-8000-000000000001";
|
|
186
|
+
let conn = rusqlite::Connection::open(copilot_home.join("session-store.db")).unwrap();
|
|
187
|
+
conn.execute(
|
|
188
|
+
"create table sessions (id text primary key, cwd text, updated_at integer)",
|
|
189
|
+
[],
|
|
190
|
+
)
|
|
191
|
+
.unwrap();
|
|
192
|
+
conn.execute(
|
|
193
|
+
"insert into sessions (id, cwd, updated_at) values (?1, '/tmp/ws', 1)",
|
|
194
|
+
[worker_id],
|
|
195
|
+
)
|
|
196
|
+
.unwrap();
|
|
197
|
+
drop(conn);
|
|
198
|
+
let previous = std::env::var_os("COPILOT_HOME");
|
|
199
|
+
std::env::set_var("COPILOT_HOME", &copilot_home);
|
|
200
|
+
let ctx = CaptureSessionContext {
|
|
201
|
+
agent_id: "worker".to_string(),
|
|
202
|
+
spawn_cwd: base.join("ws"),
|
|
203
|
+
pane_id: None,
|
|
204
|
+
pane_pid: None,
|
|
205
|
+
spawned_at: None,
|
|
206
|
+
expected_session_id: Some(SessionId::new(worker_id)),
|
|
207
|
+
provider_projects_root: None,
|
|
208
|
+
};
|
|
209
|
+
let out = scan_session_store(&ctx);
|
|
210
|
+
assert_eq!(out.len(), 1);
|
|
211
|
+
assert_eq!(
|
|
212
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
213
|
+
worker_id
|
|
214
|
+
);
|
|
215
|
+
match previous {
|
|
216
|
+
Some(value) => std::env::set_var("COPILOT_HOME", value),
|
|
217
|
+
None => std::env::remove_var("COPILOT_HOME"),
|
|
218
|
+
}
|
|
219
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
220
|
+
}
|
|
221
|
+
|
|
178
222
|
#[test]
|
|
179
223
|
#[serial_test::serial(env)]
|
|
180
224
|
fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
|
|
@@ -64,16 +64,16 @@ pub(crate) fn scan_session_candidates_once(
|
|
|
64
64
|
|
|
65
65
|
let candidates = common::candidate_session_files(provider, context)?;
|
|
66
66
|
let mut out = common::parse_candidate_files(provider, context, candidates);
|
|
67
|
-
|
|
68
67
|
if matches!(provider, Provider::Codex) {
|
|
69
|
-
codex::
|
|
68
|
+
codex::retain_spawn_cwd(context, &mut out);
|
|
70
69
|
}
|
|
70
|
+
common::apply_spawned_at_filter(provider, context, &mut out);
|
|
71
71
|
|
|
72
72
|
if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
73
73
|
return claude::apply_expected_session_filter(context, out);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
common::apply_spawn_time_window_if_unique(context, &mut out);
|
|
76
|
+
common::apply_spawn_time_window_if_unique(provider, context, &mut out);
|
|
77
77
|
common::sort_expected_first_if_needed(context, &mut out);
|
|
78
78
|
Ok(out)
|
|
79
79
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use rusqlite::Connection;
|
|
4
|
+
|
|
5
|
+
#[test]
|
|
6
|
+
#[serial_test::serial(env)]
|
|
7
|
+
fn copilot_fork_copies_isolated_home_and_rekeys_every_session_table() {
|
|
8
|
+
use crate::provider::adapters::copilot_fork::materialize_copilot_fork;
|
|
9
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
10
|
+
|
|
11
|
+
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
12
|
+
let root = std::env::temp_dir().join(format!(
|
|
13
|
+
"ta-copilot-fork-{}-{}",
|
|
14
|
+
std::process::id(),
|
|
15
|
+
COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
16
|
+
));
|
|
17
|
+
let source_home = root.join("source-home");
|
|
18
|
+
let workspace = root.join("workspace");
|
|
19
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
20
|
+
let source = SessionId::new("11111111-2222-4333-8444-555555555555");
|
|
21
|
+
seed_copilot_fork_fixture(&source_home, source.as_str(), None);
|
|
22
|
+
let source_events = std::fs::read(
|
|
23
|
+
source_home
|
|
24
|
+
.join("session-state")
|
|
25
|
+
.join(source.as_str())
|
|
26
|
+
.join("events.jsonl"),
|
|
27
|
+
)
|
|
28
|
+
.unwrap();
|
|
29
|
+
let previous = std::env::var_os("COPILOT_HOME");
|
|
30
|
+
std::env::set_var("COPILOT_HOME", &source_home);
|
|
31
|
+
|
|
32
|
+
let materialized = materialize_copilot_fork(&workspace, "fork-a", &source).unwrap();
|
|
33
|
+
assert_ne!(materialized.session_id(), &source);
|
|
34
|
+
assert!(materialized
|
|
35
|
+
.home()
|
|
36
|
+
.starts_with(workspace.join(".team/runtime/provider-session-forks/copilot/fork-a")));
|
|
37
|
+
let new_id = materialized.session_id().as_str();
|
|
38
|
+
let target_state = materialized.home().join("session-state").join(new_id);
|
|
39
|
+
assert!(target_state.is_dir());
|
|
40
|
+
assert_eq!(
|
|
41
|
+
std::fs::read_to_string(target_state.join("events.jsonl")).unwrap(),
|
|
42
|
+
format!("{{\"session_id\":\"{new_id}\"}}\n")
|
|
43
|
+
);
|
|
44
|
+
let per_session = Connection::open(target_state.join("session.db")).unwrap();
|
|
45
|
+
let recipient: String = per_session
|
|
46
|
+
.query_row(
|
|
47
|
+
"select recipient_session_id from inbox_entries",
|
|
48
|
+
[],
|
|
49
|
+
|row| row.get(0),
|
|
50
|
+
)
|
|
51
|
+
.unwrap();
|
|
52
|
+
assert_eq!(recipient, new_id);
|
|
53
|
+
|
|
54
|
+
let central = Connection::open(materialized.home().join("session-store.db")).unwrap();
|
|
55
|
+
assert_eq!(session_count(¢ral, "sessions", "id", new_id), 1);
|
|
56
|
+
assert_eq!(
|
|
57
|
+
session_count(¢ral, "sessions", "id", source.as_str()),
|
|
58
|
+
0
|
|
59
|
+
);
|
|
60
|
+
for table in [
|
|
61
|
+
"turns",
|
|
62
|
+
"checkpoints",
|
|
63
|
+
"session_files",
|
|
64
|
+
"session_refs",
|
|
65
|
+
"forge_trajectory_events",
|
|
66
|
+
"search_index",
|
|
67
|
+
] {
|
|
68
|
+
assert_eq!(session_count(¢ral, table, "session_id", new_id), 1);
|
|
69
|
+
assert_eq!(
|
|
70
|
+
session_count(¢ral, table, "session_id", source.as_str()),
|
|
71
|
+
0
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
assert_eq!(
|
|
75
|
+
std::fs::read(
|
|
76
|
+
source_home
|
|
77
|
+
.join("session-state")
|
|
78
|
+
.join(source.as_str())
|
|
79
|
+
.join("events.jsonl")
|
|
80
|
+
)
|
|
81
|
+
.unwrap(),
|
|
82
|
+
source_events,
|
|
83
|
+
"fork must not mutate source backing"
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
restore_env("COPILOT_HOME", previous);
|
|
87
|
+
let _ = std::fs::remove_dir_all(root);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[test]
|
|
91
|
+
#[serial_test::serial(env)]
|
|
92
|
+
fn copilot_fork_fails_closed_when_a_required_session_table_is_missing() {
|
|
93
|
+
let root = std::env::temp_dir().join(format!("ta-copilot-fork-missing-{}", std::process::id()));
|
|
94
|
+
let source_home = root.join("source-home");
|
|
95
|
+
let workspace = root.join("workspace");
|
|
96
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
97
|
+
let source = SessionId::new("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
|
98
|
+
seed_copilot_fork_fixture(&source_home, source.as_str(), Some("session_refs"));
|
|
99
|
+
let previous = std::env::var_os("COPILOT_HOME");
|
|
100
|
+
std::env::set_var("COPILOT_HOME", &source_home);
|
|
101
|
+
|
|
102
|
+
let error = crate::provider::adapters::copilot_fork::materialize_copilot_fork(
|
|
103
|
+
&workspace, "fork-b", &source,
|
|
104
|
+
)
|
|
105
|
+
.unwrap_err();
|
|
106
|
+
assert!(error.to_string().contains("session_refs"), "{error}");
|
|
107
|
+
let managed_parent = workspace.join(".team/runtime/provider-session-forks/copilot/fork-b");
|
|
108
|
+
assert!(
|
|
109
|
+
!managed_parent.exists() || std::fs::read_dir(managed_parent).unwrap().next().is_none(),
|
|
110
|
+
"failed materialization must leave no clone home"
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
restore_env("COPILOT_HOME", previous);
|
|
114
|
+
let _ = std::fs::remove_dir_all(root);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn seed_copilot_fork_fixture(home: &Path, source: &str, omitted: Option<&str>) {
|
|
118
|
+
let state = home.join("session-state").join(source);
|
|
119
|
+
std::fs::create_dir_all(state.join("checkpoints")).unwrap();
|
|
120
|
+
std::fs::write(
|
|
121
|
+
state.join("workspace.yaml"),
|
|
122
|
+
format!("session_id: {source}\n"),
|
|
123
|
+
)
|
|
124
|
+
.unwrap();
|
|
125
|
+
std::fs::write(
|
|
126
|
+
state.join("events.jsonl"),
|
|
127
|
+
format!("{{\"session_id\":\"{source}\"}}\n"),
|
|
128
|
+
)
|
|
129
|
+
.unwrap();
|
|
130
|
+
let session_db = Connection::open(state.join("session.db")).unwrap();
|
|
131
|
+
session_db
|
|
132
|
+
.execute_batch(
|
|
133
|
+
"create table inbox_entries (id text primary key, recipient_session_id text not null);",
|
|
134
|
+
)
|
|
135
|
+
.unwrap();
|
|
136
|
+
session_db
|
|
137
|
+
.execute("insert into inbox_entries values ('one', ?1)", [source])
|
|
138
|
+
.unwrap();
|
|
139
|
+
|
|
140
|
+
let db = Connection::open(home.join("session-store.db")).unwrap();
|
|
141
|
+
db.execute_batch(
|
|
142
|
+
"create table sessions (id text primary key);
|
|
143
|
+
create table turns (id integer primary key, session_id text not null);
|
|
144
|
+
create table checkpoints (id integer primary key, session_id text not null);
|
|
145
|
+
create table session_files (id integer primary key, session_id text not null);
|
|
146
|
+
create table session_refs (id integer primary key, session_id text not null);
|
|
147
|
+
create table forge_trajectory_events (id integer primary key, session_id text not null);
|
|
148
|
+
create virtual table search_index using fts5(content, session_id unindexed);",
|
|
149
|
+
)
|
|
150
|
+
.unwrap();
|
|
151
|
+
db.execute("insert into sessions values (?1)", [source])
|
|
152
|
+
.unwrap();
|
|
153
|
+
for table in [
|
|
154
|
+
"turns",
|
|
155
|
+
"checkpoints",
|
|
156
|
+
"session_files",
|
|
157
|
+
"session_refs",
|
|
158
|
+
"forge_trajectory_events",
|
|
159
|
+
] {
|
|
160
|
+
if omitted != Some(table) {
|
|
161
|
+
db.execute(
|
|
162
|
+
&format!("insert into {table} (session_id) values (?1)"),
|
|
163
|
+
[source],
|
|
164
|
+
)
|
|
165
|
+
.unwrap();
|
|
166
|
+
} else {
|
|
167
|
+
db.execute(&format!("drop table {table}"), []).unwrap();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
db.execute(
|
|
171
|
+
"insert into search_index (content, session_id) values ('x', ?1)",
|
|
172
|
+
[source],
|
|
173
|
+
)
|
|
174
|
+
.unwrap();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn session_count(conn: &Connection, table: &str, column: &str, id: &str) -> i64 {
|
|
178
|
+
conn.query_row(
|
|
179
|
+
&format!("select count(*) from {table} where {column} = ?1"),
|
|
180
|
+
[id],
|
|
181
|
+
|row| row.get(0),
|
|
182
|
+
)
|
|
183
|
+
.unwrap()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn restore_env(key: &str, value: Option<std::ffi::OsString>) {
|
|
187
|
+
match value {
|
|
188
|
+
Some(value) => std::env::set_var(key, value),
|
|
189
|
+
None => std::env::remove_var(key),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -12,7 +12,9 @@ use std::os::unix::net::UnixListener;
|
|
|
12
12
|
use std::path::Path;
|
|
13
13
|
use std::sync::{Arc, Mutex};
|
|
14
14
|
|
|
15
|
-
use super::{
|
|
15
|
+
use super::{
|
|
16
|
+
CommandOutput, CommandRunner, RealCommandRunner, TmuxBackend, PANE_BINDING_NONCE_METADATA_KEY,
|
|
17
|
+
};
|
|
16
18
|
use crate::model::enums::PaneLiveness;
|
|
17
19
|
use crate::transport::{
|
|
18
20
|
normalize_capture, tmux_capture_argv, tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv,
|
|
@@ -395,10 +397,7 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
|
|
|
395
397
|
let pane_inventory = "%3\tteamsess\t0\tw1\t0\t/dev/ttys003\tnode\t1\t/work/dir\t1\t0\t123\n";
|
|
396
398
|
let (be, rec) = backend_with(
|
|
397
399
|
MockResp::Out(ok("")),
|
|
398
|
-
vec![
|
|
399
|
-
MockResp::Out(ok("%3\n")),
|
|
400
|
-
MockResp::Out(ok(pane_inventory)),
|
|
401
|
-
],
|
|
400
|
+
vec![MockResp::Out(ok("%3\n")), MockResp::Out(ok(pane_inventory))],
|
|
402
401
|
);
|
|
403
402
|
let s = SessionName::new("teamsess");
|
|
404
403
|
let w = WindowName::new("w1");
|
|
@@ -443,10 +442,7 @@ fn spawn_into_frames_via_new_window_builder() {
|
|
|
443
442
|
let pane_inventory = "%4\tteamsess\t1\tw2\t0\t/dev/ttys004\tnode\t1\t/work/dir\t1\t0\t124\n";
|
|
444
443
|
let (be, rec) = backend_with(
|
|
445
444
|
MockResp::Out(ok("")),
|
|
446
|
-
vec![
|
|
447
|
-
MockResp::Out(ok("%4\n")),
|
|
448
|
-
MockResp::Out(ok(pane_inventory)),
|
|
449
|
-
],
|
|
445
|
+
vec![MockResp::Out(ok("%4\n")), MockResp::Out(ok(pane_inventory))],
|
|
450
446
|
);
|
|
451
447
|
let s = SessionName::new("teamsess");
|
|
452
448
|
let w = WindowName::new("w2");
|
|
@@ -518,10 +514,7 @@ fn spawn_with_command_refuses_and_rolls_back_spawn_pane_owned_by_other_window()
|
|
|
518
514
|
let pane_inventory = "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
|
|
519
515
|
let (be, rec) = backend_with(
|
|
520
516
|
MockResp::Out(ok("")),
|
|
521
|
-
vec![
|
|
522
|
-
MockResp::Out(ok("%5\n")),
|
|
523
|
-
MockResp::Out(ok(pane_inventory)),
|
|
524
|
-
],
|
|
517
|
+
vec![MockResp::Out(ok("%5\n")), MockResp::Out(ok(pane_inventory))],
|
|
525
518
|
);
|
|
526
519
|
let err = be
|
|
527
520
|
.spawn_into(
|
|
@@ -1791,9 +1784,9 @@ fn query_single_field_argv_and_nonzero_maps_to_none() {
|
|
|
1791
1784
|
// display-message N+1 fallback is gone). leader_env stays the reverse-env real-machine bit.
|
|
1792
1785
|
#[test]
|
|
1793
1786
|
fn list_targets_argv_and_parses_tmux_pane_format() {
|
|
1794
|
-
const FMT: &str = "#{pane_id}__TA_FIELD__#{session_name}__TA_FIELD__#{window_index}__TA_FIELD__#{window_name}__TA_FIELD__#{pane_index}__TA_FIELD__#{pane_tty}__TA_FIELD__#{pane_current_command}__TA_FIELD__#{pane_active}__TA_FIELD__#{pane_current_path}__TA_FIELD__#{session_attached}__TA_FIELD__#{pane_in_mode}__TA_FIELD__#{pane_pid}";
|
|
1795
|
-
let stdout = "%7__TA_FIELD__team-x__TA_FIELD__0__TA_FIELD__win0__TA_FIELD__0__TA_FIELD__/dev/ttys003__TA_FIELD__codex__TA_FIELD__1__TA_FIELD__/Users/me/
|
|
1796
|
-
%8__TA_FIELD__team-x__TA_FIELD__1__TA_FIELD__win1__TA_FIELD__0__TA_FIELD__/dev/ttys004__TA_FIELD__node__TA_FIELD__0__TA_FIELD__/Users/me/
|
|
1787
|
+
const FMT: &str = "#{pane_id}__TA_FIELD__#{session_name}__TA_FIELD__#{window_index}__TA_FIELD__#{window_name}__TA_FIELD__#{pane_index}__TA_FIELD__#{pane_tty}__TA_FIELD__#{pane_current_command}__TA_FIELD__#{pane_active}__TA_FIELD__#{pane_current_path}__TA_FIELD__#{session_attached}__TA_FIELD__#{pane_in_mode}__TA_FIELD__#{pane_pid}__TA_FIELD__#{@team_agent_pane_binding_nonce}";
|
|
1788
|
+
let stdout = "%7__TA_FIELD__team-x__TA_FIELD__0__TA_FIELD__win0__TA_FIELD__0__TA_FIELD__/dev/ttys003__TA_FIELD__codex__TA_FIELD__1__TA_FIELD__/Users/me/work__TA_FIELD__1__TA_FIELD__0__TA_FIELD__41001__TA_FIELD__nonce-7\n\
|
|
1789
|
+
%8__TA_FIELD__team-x__TA_FIELD__1__TA_FIELD__win1__TA_FIELD__0__TA_FIELD__/dev/ttys004__TA_FIELD__node__TA_FIELD__0__TA_FIELD__/Users/me/other__TA_FIELD__0__TA_FIELD__0__TA_FIELD__41002__TA_FIELD__\n";
|
|
1797
1790
|
let (be, rec) = backend_with(MockResp::Out(ok(stdout)), vec![]);
|
|
1798
1791
|
let panes = be.list_targets().expect("list_targets ok");
|
|
1799
1792
|
assert_eq!(
|
|
@@ -1845,6 +1838,19 @@ fn list_targets_argv_and_parses_tmux_pane_format() {
|
|
|
1845
1838
|
Some(41002),
|
|
1846
1839
|
"field[11] -> pane_pid (second pane)"
|
|
1847
1840
|
);
|
|
1841
|
+
assert_eq!(
|
|
1842
|
+
p.leader_env
|
|
1843
|
+
.get(PANE_BINDING_NONCE_METADATA_KEY)
|
|
1844
|
+
.map(String::as_str),
|
|
1845
|
+
Some("nonce-7"),
|
|
1846
|
+
"field[12] pane option -> pane-instance binding metadata"
|
|
1847
|
+
);
|
|
1848
|
+
assert!(
|
|
1849
|
+
!panes[1]
|
|
1850
|
+
.leader_env
|
|
1851
|
+
.contains_key(PANE_BINDING_NONCE_METADATA_KEY),
|
|
1852
|
+
"an empty pane option must remain absent"
|
|
1853
|
+
);
|
|
1848
1854
|
|
|
1849
1855
|
// nonzero exit -> empty vec (golden returncode != 0 -> []).
|
|
1850
1856
|
let (be, _r) = backend_with(
|
|
@@ -1859,6 +1865,35 @@ fn list_targets_argv_and_parses_tmux_pane_format() {
|
|
|
1859
1865
|
);
|
|
1860
1866
|
}
|
|
1861
1867
|
|
|
1868
|
+
#[test]
|
|
1869
|
+
fn set_pane_binding_nonce_is_socket_scoped_and_pane_local() {
|
|
1870
|
+
let rec = Arc::new(Mutex::new(Vec::new()));
|
|
1871
|
+
let runner = MockCommandRunner {
|
|
1872
|
+
recorded: Arc::clone(&rec),
|
|
1873
|
+
stdin_recorded: Arc::new(Mutex::new(Vec::new())),
|
|
1874
|
+
queue: Mutex::new(VecDeque::new()),
|
|
1875
|
+
default: MockResp::Out(ok("")),
|
|
1876
|
+
};
|
|
1877
|
+
let be =
|
|
1878
|
+
TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), "/tmp/ta-pane-binding.sock");
|
|
1879
|
+
be.set_pane_binding_nonce(&PaneId::new("%7"), "nonce-7")
|
|
1880
|
+
.expect("set pane binding nonce");
|
|
1881
|
+
assert_eq!(
|
|
1882
|
+
rec.lock().unwrap()[0],
|
|
1883
|
+
svec(&[
|
|
1884
|
+
"tmux",
|
|
1885
|
+
"-S",
|
|
1886
|
+
"/tmp/ta-pane-binding.sock",
|
|
1887
|
+
"set-option",
|
|
1888
|
+
"-p",
|
|
1889
|
+
"-t",
|
|
1890
|
+
"%7",
|
|
1891
|
+
"@team_agent_pane_binding_nonce",
|
|
1892
|
+
"nonce-7",
|
|
1893
|
+
])
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1862
1897
|
// ── 12. attach_session (TRANSPORT TRIO) — `tmux attach-session -t <s>` -> Attached ──────────────
|
|
1863
1898
|
// Golden tmux attach is `tmux attach-session -t <session>`; a successful attach -> AttachOutcome::
|
|
1864
1899
|
// Attached. RED today: attach_session is unimplemented!() -> PANIC. The in-process lock asserts the
|