@team-agent/installer 0.5.99 → 0.5.101
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 +1 -156
- package/crates/team-agent/src/cli/diagnose.rs +32 -10
- package/crates/team-agent/src/cli/emit.rs +6 -185
- package/crates/team-agent/src/cli/mod.rs +32 -182
- package/crates/team-agent/src/cli/send/coordinator.rs +2 -2
- package/crates/team-agent/src/cli/spec.rs +41 -67
- package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -185
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +9 -9
- package/crates/team-agent/src/cli/triage.rs +1 -1
- package/crates/team-agent/src/cli/types.rs +0 -49
- package/crates/team-agent/src/compiler.rs +3 -0
- package/crates/team-agent/src/leader/lease.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +211 -16
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +15 -5
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +89 -6
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +168 -32
- package/crates/team-agent/src/lifecycle/launch.rs +11 -3
- package/crates/team-agent/src/lifecycle/profile_launch.rs +23 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +2 -2
- package/crates/team-agent/src/lifecycle/restart/avatar.rs +1806 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +114 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +14 -8
- package/crates/team-agent/src/lifecycle/restart/selection.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart.rs +11 -4
- package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +9 -2
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1398 -0
- package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +0 -60
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +10 -2
- package/crates/team-agent/src/messaging/send.rs +2 -2
- package/crates/team-agent/src/model/spec.rs +1 -0
- package/crates/team-agent/src/provider/types.rs +3 -1
- package/crates/team-agent/src/state/persist.rs +58 -2
- package/crates/team-agent/src/state/projection.rs +5 -1
- package/crates/team-agent/src/state/repository.rs +125 -9
- package/crates/team-agent/src/transport/test_support.rs +23 -3
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +17 -46
- package/skills/team-agent/references/recovery-runbook.md +2 -2
- package/skills/team-agent/references/team-agent-operator.md +4 -2
- package/skills/team-agent/references/team-in-team.md +0 -6
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
//! Independent R01–R12 contracts for Pi `fork-agent` session inheritance.
|
|
2
|
+
//!
|
|
3
|
+
//! These tests exercise the public lifecycle port and existing lifecycle/state
|
|
4
|
+
//! surfaces only. No implementation-only fork helper is assumed by the baseline.
|
|
5
|
+
//! The frozen baseline is expected to fail R01 at its current Pi native-fork
|
|
6
|
+
//! refusal, after the fixture has independently proved a valid source tuple.
|
|
7
|
+
|
|
8
|
+
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
|
9
|
+
|
|
10
|
+
#[path = "../../../tests/support/hermetic.rs"]
|
|
11
|
+
mod hermetic_guard;
|
|
12
|
+
#[allow(dead_code)]
|
|
13
|
+
fn _hermetic_boundary_marker(_: &hermetic_guard::HermeticTestEnv) {}
|
|
14
|
+
|
|
15
|
+
use std::fs;
|
|
16
|
+
#[cfg(unix)]
|
|
17
|
+
use std::os::unix::fs::PermissionsExt;
|
|
18
|
+
use std::path::{Path, PathBuf};
|
|
19
|
+
use std::time::{Duration, Instant};
|
|
20
|
+
|
|
21
|
+
use crate as team_agent;
|
|
22
|
+
use serde_json::{Value, json};
|
|
23
|
+
use serial_test::serial;
|
|
24
|
+
use team_agent::lifecycle::launch::{fork_agent_with_transport, pi_mcp::pi_seat_paths};
|
|
25
|
+
use team_agent::model::ids::AgentId;
|
|
26
|
+
use team_agent::provider::{AuthMode, Provider, SessionId, get_adapter};
|
|
27
|
+
use team_agent::state::persist::{load_runtime_state, runtime_state_path};
|
|
28
|
+
use team_agent::transport::test_support::OfflineTransport;
|
|
29
|
+
|
|
30
|
+
const SOURCE: &str = "implementer";
|
|
31
|
+
const TARGET: &str = "variant";
|
|
32
|
+
const SESSION_A: &str = "652756ea-aaee-4437-a30f-40c5606826d5";
|
|
33
|
+
const CAPTURED_AT: &str = "2026-09-12T10:00:02Z";
|
|
34
|
+
const SESSION_TIMESTAMP: &str = "2025-01-02T03:04:05.000Z";
|
|
35
|
+
const PROPERTY_SEED: u64 = 0x5eed_f0a7_1209_2026;
|
|
36
|
+
|
|
37
|
+
const ROLE_DOC: &str = "---\nname: implementer\nrole: Session inheritance fixture\nprovider: pi\nmodel: team-agent/qwen3.8-27b\nauth_mode: subscription\neffort: high\ndangerously_skip_permissions: false\ntools:\n - mcp_team\ncommunication_mode: orchestrated\n---\n\nKeep the complete role body: 雪だるま 🛰️.\n";
|
|
38
|
+
const SOURCE_ROLE: &str = "---\nname: implementer\nrole: Dynamic source role\nprovider: pi\nmodel: team-agent/qwen3.8-27b\nauth_mode: subscription\neffort: high\ndangerously_skip_permissions: false\ntools:\n - mcp_team\ncommunication_mode: orchestrated\nprofile: research\n---\n\nDynamic body stays exact: use Δ, 雪, and the captured tool result.\n";
|
|
39
|
+
|
|
40
|
+
#[cfg(unix)]
|
|
41
|
+
struct PathEnvGuard {
|
|
42
|
+
previous: Option<std::ffi::OsString>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[cfg(unix)]
|
|
46
|
+
impl PathEnvGuard {
|
|
47
|
+
fn with_fake_pi(root: &Path) -> Self {
|
|
48
|
+
let wrapper_bin = root.join("bin/wrapper");
|
|
49
|
+
let real_bin = root.join("bin/real");
|
|
50
|
+
let package_root = root.join("node_modules/pi-mcp-adapter");
|
|
51
|
+
fs::create_dir_all(&wrapper_bin).expect("create fake wrapper bin");
|
|
52
|
+
fs::create_dir_all(&real_bin).expect("create fake real bin");
|
|
53
|
+
fs::create_dir_all(&package_root).expect("create fake adapter package");
|
|
54
|
+
let wrapper = wrapper_bin.join("pi");
|
|
55
|
+
let wrapper_script = format!(
|
|
56
|
+
"#!/bin/sh\ncase \"$1\" in\n--version) echo 0.87.1 ;;\n--list-models) printf 'provider model\\nteam-agent qwen3.8-27b\\n' ;;\nlist) printf 'npm:pi-mcp-adapter\\n{}\\n' ;;\nesac\nexit 0\n",
|
|
57
|
+
package_root.display()
|
|
58
|
+
);
|
|
59
|
+
fs::write(&wrapper, wrapper_script).expect("write offline Pi wrapper");
|
|
60
|
+
let real = real_bin.join("pi");
|
|
61
|
+
fs::write(&real, "#!/bin/sh\necho 0.87.1\nexit 0\n")
|
|
62
|
+
.expect("write offline Pi real-binary stand-in");
|
|
63
|
+
let package =
|
|
64
|
+
json!({"name":"pi-mcp-adapter","version":"0.1.0","pi":{"extensions":["./index.ts"]}});
|
|
65
|
+
fs::write(package_root.join("package.json"), package.to_string())
|
|
66
|
+
.expect("write offline adapter package metadata");
|
|
67
|
+
fs::write(package_root.join("index.ts"), "export {};\n")
|
|
68
|
+
.expect("write offline adapter extension entry");
|
|
69
|
+
for pi in [&wrapper, &real] {
|
|
70
|
+
let mut permissions = fs::metadata(pi).expect("fake Pi metadata").permissions();
|
|
71
|
+
permissions.set_mode(0o755);
|
|
72
|
+
fs::set_permissions(pi, permissions).expect("make fake Pi executable");
|
|
73
|
+
}
|
|
74
|
+
let previous = std::env::var_os("PATH");
|
|
75
|
+
let mut paths = vec![wrapper_bin, real_bin];
|
|
76
|
+
if let Some(existing) = previous.as_deref() {
|
|
77
|
+
paths.extend(std::env::split_paths(existing));
|
|
78
|
+
}
|
|
79
|
+
let path = std::env::join_paths(paths).expect("join fixture PATH");
|
|
80
|
+
unsafe { std::env::set_var("PATH", path) };
|
|
81
|
+
let version = std::process::Command::new("pi")
|
|
82
|
+
.arg("--version")
|
|
83
|
+
.output()
|
|
84
|
+
.expect("fake Pi must resolve through fixture PATH");
|
|
85
|
+
assert!(version.status.success(), "fake Pi must be executable");
|
|
86
|
+
Self { previous }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[cfg(unix)]
|
|
91
|
+
impl Drop for PathEnvGuard {
|
|
92
|
+
fn drop(&mut self) {
|
|
93
|
+
if let Some(path) = self.previous.take() {
|
|
94
|
+
unsafe { std::env::set_var("PATH", path) };
|
|
95
|
+
} else {
|
|
96
|
+
unsafe { std::env::remove_var("PATH") };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
struct Fixture {
|
|
102
|
+
#[cfg(unix)]
|
|
103
|
+
_path_guard: PathEnvGuard,
|
|
104
|
+
root: PathBuf,
|
|
105
|
+
team_dir: PathBuf,
|
|
106
|
+
run_workspace: PathBuf,
|
|
107
|
+
team_key: String,
|
|
108
|
+
state_path: PathBuf,
|
|
109
|
+
source_paths: team_agent::lifecycle::launch::pi_mcp::PiSeatPaths,
|
|
110
|
+
source_file: PathBuf,
|
|
111
|
+
source_body: Vec<u8>,
|
|
112
|
+
dynamic_role: PathBuf,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
impl Fixture {
|
|
116
|
+
fn new() -> Self {
|
|
117
|
+
let root = super::temp_ws();
|
|
118
|
+
#[cfg(unix)]
|
|
119
|
+
let path_guard = PathEnvGuard::with_fake_pi(&root);
|
|
120
|
+
let team_dir = root.join("teamdir");
|
|
121
|
+
fs::create_dir_all(team_dir.join("agents")).expect("create team roles");
|
|
122
|
+
fs::create_dir_all(team_dir.join("profiles")).expect("create role profiles");
|
|
123
|
+
fs::write(
|
|
124
|
+
team_dir.join("TEAM.md"),
|
|
125
|
+
"---\nname: fork-contract-team\nobjective: Session inheritance fixture.\nprovider: codex\n---\n\nTeam fixture.\n",
|
|
126
|
+
)
|
|
127
|
+
.expect("write team definition");
|
|
128
|
+
fs::write(team_dir.join("agents").join("implementer.md"), ROLE_DOC)
|
|
129
|
+
.expect("write source role");
|
|
130
|
+
fs::write(
|
|
131
|
+
team_dir.join("profiles").join("research.yaml"),
|
|
132
|
+
"temperature: 0.25\n",
|
|
133
|
+
)
|
|
134
|
+
.expect("write referenced profile");
|
|
135
|
+
|
|
136
|
+
let setup = team_agent::lifecycle::launch::quick_start_with_transport(
|
|
137
|
+
&team_dir,
|
|
138
|
+
None,
|
|
139
|
+
true,
|
|
140
|
+
None,
|
|
141
|
+
&OfflineTransport::new(),
|
|
142
|
+
)
|
|
143
|
+
.expect("valid Pi team fixture must quick-start");
|
|
144
|
+
assert!(
|
|
145
|
+
matches!(setup, team_agent::lifecycle::QuickStartReport::Ready { .. }),
|
|
146
|
+
"fixture must be a started/selectable team, not a failed quick-start: {setup:?}"
|
|
147
|
+
);
|
|
148
|
+
let selected = team_agent::state::selector::resolve_active_team(
|
|
149
|
+
&team_dir,
|
|
150
|
+
None,
|
|
151
|
+
team_agent::state::selector::SelectorMode::RequireSpec,
|
|
152
|
+
)
|
|
153
|
+
.expect("quick-start must create a selectable team with a spec");
|
|
154
|
+
let run_workspace = selected.run_workspace.clone();
|
|
155
|
+
let team_key = selected.team_key.clone();
|
|
156
|
+
let state_path = runtime_state_path(&run_workspace);
|
|
157
|
+
let source_paths = pi_seat_paths(&run_workspace, &team_key, SOURCE);
|
|
158
|
+
let source_file = source_paths.sessions.join("2025/01/02/session-A.jsonl");
|
|
159
|
+
fs::create_dir_all(source_file.parent().expect("source file parent"))
|
|
160
|
+
.expect("create source session tree");
|
|
161
|
+
let source_body = valid_v3_body();
|
|
162
|
+
write_session(
|
|
163
|
+
&source_file,
|
|
164
|
+
SESSION_A,
|
|
165
|
+
&run_workspace,
|
|
166
|
+
SESSION_TIMESTAMP,
|
|
167
|
+
&source_body,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
let dynamic_role = run_workspace
|
|
171
|
+
.join(".team/runtime/dynamic-role-files")
|
|
172
|
+
.join(&team_key)
|
|
173
|
+
.join("implementer.md");
|
|
174
|
+
fs::create_dir_all(dynamic_role.parent().expect("dynamic role parent"))
|
|
175
|
+
.expect("create dynamic role directory");
|
|
176
|
+
fs::write(&dynamic_role, SOURCE_ROLE).expect("write current dynamic role");
|
|
177
|
+
|
|
178
|
+
let mut state = selected.state.clone();
|
|
179
|
+
if !state.get("agents").is_some_and(Value::is_object) {
|
|
180
|
+
state["agents"] = json!({});
|
|
181
|
+
}
|
|
182
|
+
if !state["agents"][SOURCE].is_object() {
|
|
183
|
+
state["agents"][SOURCE] = json!({});
|
|
184
|
+
}
|
|
185
|
+
let source = state["agents"][SOURCE]
|
|
186
|
+
.as_object_mut()
|
|
187
|
+
.expect("source row is an object");
|
|
188
|
+
source.insert("provider".to_string(), json!("pi"));
|
|
189
|
+
source.insert("auth_mode".to_string(), json!("subscription"));
|
|
190
|
+
source.insert(
|
|
191
|
+
"spawn_cwd".to_string(),
|
|
192
|
+
json!(run_workspace.to_string_lossy().to_string()),
|
|
193
|
+
);
|
|
194
|
+
source.insert("session_id".to_string(), json!(SESSION_A));
|
|
195
|
+
source.insert(
|
|
196
|
+
"rollout_path".to_string(),
|
|
197
|
+
json!(source_file.to_string_lossy().to_string()),
|
|
198
|
+
);
|
|
199
|
+
source.insert("captured_at".to_string(), json!(CAPTURED_AT));
|
|
200
|
+
source.insert("captured_via".to_string(), json!("session_scan"));
|
|
201
|
+
source.insert("capture_state".to_string(), json!("captured"));
|
|
202
|
+
source.insert(
|
|
203
|
+
"dynamic_role_file".to_string(),
|
|
204
|
+
json!(dynamic_role.to_string_lossy().to_string()),
|
|
205
|
+
);
|
|
206
|
+
team_agent::state::projection::save_team_scoped_state(&run_workspace, &state)
|
|
207
|
+
.expect("persist valid Pi source tuple");
|
|
208
|
+
|
|
209
|
+
let fixture = Self {
|
|
210
|
+
#[cfg(unix)]
|
|
211
|
+
_path_guard: path_guard,
|
|
212
|
+
root,
|
|
213
|
+
team_dir,
|
|
214
|
+
run_workspace,
|
|
215
|
+
team_key,
|
|
216
|
+
state_path,
|
|
217
|
+
source_paths,
|
|
218
|
+
source_file,
|
|
219
|
+
source_body,
|
|
220
|
+
dynamic_role,
|
|
221
|
+
};
|
|
222
|
+
fixture.assert_valid_source_fixture();
|
|
223
|
+
fixture
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn assert_valid_source_fixture(&self) {
|
|
227
|
+
let state = self.state();
|
|
228
|
+
let source = &state["agents"][SOURCE];
|
|
229
|
+
assert_eq!(source["provider"], "pi", "fixture source must be Pi");
|
|
230
|
+
assert_eq!(source["auth_mode"], "subscription");
|
|
231
|
+
assert_eq!(source["session_id"], SESSION_A);
|
|
232
|
+
assert_eq!(
|
|
233
|
+
source["rollout_path"].as_str(),
|
|
234
|
+
Some(self.source_file.to_string_lossy().as_ref())
|
|
235
|
+
);
|
|
236
|
+
assert!(
|
|
237
|
+
self.source_file.is_file(),
|
|
238
|
+
"source backing must be a regular file"
|
|
239
|
+
);
|
|
240
|
+
let header = read_header(&self.source_file);
|
|
241
|
+
assert_eq!(header["version"], 3, "source fixture must be a v3 session");
|
|
242
|
+
assert_eq!(header["id"], SESSION_A);
|
|
243
|
+
assert_eq!(header["cwd"], self.run_workspace.to_string_lossy().as_ref());
|
|
244
|
+
assert_eq!(header["timestamp"], SESSION_TIMESTAMP);
|
|
245
|
+
let spawned_at = source["spawned_at"]
|
|
246
|
+
.as_str()
|
|
247
|
+
.expect("source spawn generation");
|
|
248
|
+
assert!(header["timestamp"].as_str().unwrap() < spawned_at);
|
|
249
|
+
assert_valid_entry_tree(&body_bytes(&self.source_file));
|
|
250
|
+
assert!(!self.dynamic_role.as_os_str().is_empty());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn state(&self) -> Value {
|
|
254
|
+
let full = load_runtime_state(&self.run_workspace).expect("load fixture runtime state");
|
|
255
|
+
team_agent::state::projection::project_top_level_view(&full, &self.team_key)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fn save_state(&self, state: &Value) {
|
|
259
|
+
team_agent::state::projection::save_team_scoped_state(&self.run_workspace, state)
|
|
260
|
+
.expect("save fixture runtime state");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fn target_paths(&self, target: &str) -> team_agent::lifecycle::launch::pi_mcp::PiSeatPaths {
|
|
264
|
+
pi_seat_paths(&self.run_workspace, &self.team_key, target)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn transport(&self) -> OfflineTransport {
|
|
268
|
+
OfflineTransport::new()
|
|
269
|
+
.with_session_present(true)
|
|
270
|
+
.with_windows(vec![team_agent::transport::WindowName::new(SOURCE)])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn fork(
|
|
274
|
+
&self,
|
|
275
|
+
target: &str,
|
|
276
|
+
label: Option<&str>,
|
|
277
|
+
transport: &dyn team_agent::transport::Transport,
|
|
278
|
+
) -> Result<team_agent::lifecycle::ForkAgentReport, team_agent::lifecycle::LifecycleError> {
|
|
279
|
+
self.fork_from(SOURCE, target, label, transport)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn fork_from(
|
|
283
|
+
&self,
|
|
284
|
+
source: &str,
|
|
285
|
+
target: &str,
|
|
286
|
+
label: Option<&str>,
|
|
287
|
+
transport: &dyn team_agent::transport::Transport,
|
|
288
|
+
) -> Result<team_agent::lifecycle::ForkAgentReport, team_agent::lifecycle::LifecycleError> {
|
|
289
|
+
fork_agent_with_transport(
|
|
290
|
+
&self.team_dir,
|
|
291
|
+
&AgentId::new(source),
|
|
292
|
+
&AgentId::new(target),
|
|
293
|
+
label,
|
|
294
|
+
false,
|
|
295
|
+
Some(&self.team_key),
|
|
296
|
+
transport,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fn assert_no_target(&self, target: &str, transport: &OfflineTransport) {
|
|
301
|
+
let state = self.state();
|
|
302
|
+
assert!(
|
|
303
|
+
!state["agents"].get(target).is_some(),
|
|
304
|
+
"rejected fork must not register a usable target; state={state}"
|
|
305
|
+
);
|
|
306
|
+
let paths = self.target_paths(target);
|
|
307
|
+
assert!(
|
|
308
|
+
!paths.wrapper.exists(),
|
|
309
|
+
"rejected fork must not leave a usable wrapper"
|
|
310
|
+
);
|
|
311
|
+
assert!(
|
|
312
|
+
!transport
|
|
313
|
+
.spawn_records()
|
|
314
|
+
.iter()
|
|
315
|
+
.any(|(_, argv)| argv.iter().any(|arg| arg.contains(target))),
|
|
316
|
+
"rejected fork must not spawn a target pane"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
impl Drop for Fixture {
|
|
322
|
+
fn drop(&mut self) {
|
|
323
|
+
let _ = fs::remove_dir_all(&self.root);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
fn append_session_entry(path: &Path, entry: Value) {
|
|
328
|
+
let mut bytes = fs::read(path).expect("read session before append");
|
|
329
|
+
bytes.extend_from_slice(entry.to_string().as_bytes());
|
|
330
|
+
bytes.push(b'\n');
|
|
331
|
+
fs::write(path, bytes).expect("append independent session entry");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[cfg(unix)]
|
|
335
|
+
fn inode(path: &Path) -> u64 {
|
|
336
|
+
std::os::unix::fs::MetadataExt::ino(&fs::metadata(path).expect("file metadata"))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fn valid_v3_body() -> Vec<u8> {
|
|
340
|
+
// Stable one-seed generated branch keeps property inputs reproducible.
|
|
341
|
+
let branch_id = format!(
|
|
342
|
+
"branch-{:016x}",
|
|
343
|
+
PROPERTY_SEED ^ PROPERTY_SEED.rotate_left(17)
|
|
344
|
+
);
|
|
345
|
+
let entries = [
|
|
346
|
+
json!({
|
|
347
|
+
"type": "message", "id": "entry-user", "parentId": null,
|
|
348
|
+
"timestamp": "2025-01-02T03:04:06.000Z",
|
|
349
|
+
"message": {"role": "user", "content": [
|
|
350
|
+
{"type": "text", "text": "共同历史:雪だるま 🛰️; session A is ordinary body text."},
|
|
351
|
+
{"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"}
|
|
352
|
+
]}
|
|
353
|
+
}),
|
|
354
|
+
json!({
|
|
355
|
+
"type": "message", "id": "entry-tool-call", "parentId": "entry-user",
|
|
356
|
+
"timestamp": "2025-01-02T03:04:07.000Z",
|
|
357
|
+
"message": {"role": "assistant", "content": [
|
|
358
|
+
{"type": "text", "text": "Inspect the captured fixture."},
|
|
359
|
+
{"type": "toolCall", "id": "tool-1", "name": "read_file", "arguments": {"path": "notes/Δ.md"}}
|
|
360
|
+
], "provider": "fixture-provider", "opaqueSignature": "signed/東京/🔒"}
|
|
361
|
+
}),
|
|
362
|
+
json!({
|
|
363
|
+
"type": "tool_execution_start", "id": "entry-tool-start", "parentId": "entry-tool-call",
|
|
364
|
+
"timestamp": "2025-01-02T03:04:08.000Z", "toolCallId": "tool-1",
|
|
365
|
+
"toolName": "read_file", "args": {"path": "notes/Δ.md"}
|
|
366
|
+
}),
|
|
367
|
+
json!({
|
|
368
|
+
"type": "tool_execution_end", "id": "entry-tool-result", "parentId": "entry-tool-start",
|
|
369
|
+
"timestamp": "2025-01-02T03:04:09.000Z", "toolCallId": "tool-1",
|
|
370
|
+
"toolName": "read_file", "result": [{"type": "text", "text": "result: preserve me exactly"}],
|
|
371
|
+
"isError": false
|
|
372
|
+
}),
|
|
373
|
+
json!({
|
|
374
|
+
"type": "message", "id": "entry-system-patch", "parentId": "entry-tool-result",
|
|
375
|
+
"timestamp": "2025-01-02T03:04:10.000Z",
|
|
376
|
+
"message": {"role": "system", "content": [{"type": "text", "text": "System patch: retain role-body and profile context."}]}
|
|
377
|
+
}),
|
|
378
|
+
json!({
|
|
379
|
+
"type": "custom", "id": "entry-custom", "parentId": "entry-system-patch",
|
|
380
|
+
"timestamp": "2025-01-02T03:04:11.000Z", "customType": "context_edit",
|
|
381
|
+
"data": {"text": "custom context edit: 東京", "signed": "do-not-rewrite-A"}
|
|
382
|
+
}),
|
|
383
|
+
json!({
|
|
384
|
+
"type": "compaction", "id": "entry-compaction", "parentId": "entry-custom",
|
|
385
|
+
"timestamp": "2025-01-02T03:04:12.000Z", "summary": "Keep the complete branch ancestry.",
|
|
386
|
+
"firstKeptEntryId": "entry-system-patch", "tokensBefore": 1234
|
|
387
|
+
}),
|
|
388
|
+
json!({
|
|
389
|
+
"type": "message", "id": branch_id, "parentId": "entry-system-patch",
|
|
390
|
+
"timestamp": "2025-01-02T03:04:13.000Z",
|
|
391
|
+
"message": {"role": "assistant", "content": [{"type": "text", "text": "alternate branch answer"}]}
|
|
392
|
+
}),
|
|
393
|
+
];
|
|
394
|
+
let mut body = Vec::new();
|
|
395
|
+
for entry in entries {
|
|
396
|
+
body.extend_from_slice(entry.to_string().as_bytes());
|
|
397
|
+
body.push(b'\n');
|
|
398
|
+
}
|
|
399
|
+
body
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fn write_session(path: &Path, id: &str, cwd: &Path, timestamp: &str, body: &[u8]) {
|
|
403
|
+
fs::create_dir_all(path.parent().expect("session file parent")).expect("create session parent");
|
|
404
|
+
let header = json!({
|
|
405
|
+
"type": "session", "version": 3, "id": id,
|
|
406
|
+
"timestamp": timestamp, "cwd": cwd.to_string_lossy()
|
|
407
|
+
});
|
|
408
|
+
let mut bytes = header.to_string().into_bytes();
|
|
409
|
+
bytes.push(b'\n');
|
|
410
|
+
bytes.extend_from_slice(body);
|
|
411
|
+
fs::write(path, bytes).expect("write session fixture");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn read_header(path: &Path) -> Value {
|
|
415
|
+
let bytes = fs::read(path).expect("read session file");
|
|
416
|
+
let first_line_end = bytes
|
|
417
|
+
.iter()
|
|
418
|
+
.position(|byte| *byte == b'\n')
|
|
419
|
+
.expect("header newline");
|
|
420
|
+
serde_json::from_slice(&bytes[..first_line_end]).expect("valid session header JSON")
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fn body_bytes(path: &Path) -> Vec<u8> {
|
|
424
|
+
let bytes = fs::read(path).expect("read session file");
|
|
425
|
+
let first_line_end = bytes
|
|
426
|
+
.iter()
|
|
427
|
+
.position(|byte| *byte == b'\n')
|
|
428
|
+
.expect("header newline");
|
|
429
|
+
bytes[first_line_end + 1..].to_vec()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fn assert_valid_entry_tree(body: &[u8]) {
|
|
433
|
+
let mut ids = std::collections::BTreeSet::new();
|
|
434
|
+
for (index, line) in body
|
|
435
|
+
.split(|byte| *byte == b'\n')
|
|
436
|
+
.filter(|line| !line.is_empty())
|
|
437
|
+
.enumerate()
|
|
438
|
+
{
|
|
439
|
+
let entry: Value = serde_json::from_slice(line).expect("entry must be valid JSON");
|
|
440
|
+
let id = entry["id"].as_str().expect("entry id");
|
|
441
|
+
assert!(ids.insert(id.to_string()), "entry ids must be unique: {id}");
|
|
442
|
+
if !entry["parentId"].is_null() {
|
|
443
|
+
let parent = entry["parentId"].as_str().expect("parent id string");
|
|
444
|
+
assert!(
|
|
445
|
+
ids.contains(parent),
|
|
446
|
+
"entry {index} parent must precede it: {parent}"
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
assert!(
|
|
451
|
+
ids.len() >= 8,
|
|
452
|
+
"v3 fixture includes messages, tool results, and branches"
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
fn role_field<'a>(role: &'a str, key: &str) -> Option<&'a str> {
|
|
457
|
+
let mut in_front_matter = false;
|
|
458
|
+
for line in role.lines() {
|
|
459
|
+
if line.trim() == "---" {
|
|
460
|
+
if in_front_matter {
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
in_front_matter = true;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if in_front_matter {
|
|
467
|
+
if let Some((field, value)) = line.split_once(':') {
|
|
468
|
+
if field.trim() == key {
|
|
469
|
+
return Some(value.trim());
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
None
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fn role_body(role: &str) -> &str {
|
|
478
|
+
let first = role.find("---").expect("role front matter opener") + 3;
|
|
479
|
+
let second = role[first..].find("---").expect("role front matter closer") + first + 3;
|
|
480
|
+
role[second..].trim_start_matches(&['\r', '\n'][..])
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
fn forked_target_file(fixture: &Fixture, state: &Value, target: &str) -> PathBuf {
|
|
484
|
+
let path = state["agents"][target]["rollout_path"]
|
|
485
|
+
.as_str()
|
|
486
|
+
.expect("successful fork registers target rollout_path");
|
|
487
|
+
PathBuf::from(path)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#[test]
|
|
491
|
+
#[serial(env)]
|
|
492
|
+
fn r01_public_lifecycle_port_pi_fork_must_create_a_distinct_seat() {
|
|
493
|
+
let fixture = Fixture::new();
|
|
494
|
+
let source = fixture.state()["agents"][SOURCE].clone();
|
|
495
|
+
assert_eq!(source["provider"], "pi");
|
|
496
|
+
assert_eq!(source["session_id"], SESSION_A);
|
|
497
|
+
assert!(fixture.source_file.is_file());
|
|
498
|
+
assert_valid_entry_tree(&fixture.source_body);
|
|
499
|
+
|
|
500
|
+
let response = team_agent::cli::lifecycle_port::fork_agent(
|
|
501
|
+
&fixture.team_dir,
|
|
502
|
+
SOURCE,
|
|
503
|
+
TARGET,
|
|
504
|
+
None,
|
|
505
|
+
Some(&fixture.team_key),
|
|
506
|
+
)
|
|
507
|
+
.expect("public lifecycle port returns its JSON result");
|
|
508
|
+
assert_eq!(
|
|
509
|
+
response["ok"], true,
|
|
510
|
+
"R01: valid Pi fork-agent SOURCE --as TARGET must create a managed new seat through the public lifecycle port; baseline currently refuses Pi native session fork. response={response}"
|
|
511
|
+
);
|
|
512
|
+
assert_eq!(response["source_agent_id"], SOURCE);
|
|
513
|
+
assert_eq!(response["new_agent_id"], TARGET);
|
|
514
|
+
assert_ne!(response["session_id"], SESSION_A);
|
|
515
|
+
let after = fixture.state();
|
|
516
|
+
assert!(
|
|
517
|
+
after["agents"].get(TARGET).is_some(),
|
|
518
|
+
"public fork must register TARGET"
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#[test]
|
|
523
|
+
#[serial(env)]
|
|
524
|
+
fn r02_r03_fork_is_an_independent_identity_with_the_exact_complete_v3_tree() {
|
|
525
|
+
let fixture = Fixture::new();
|
|
526
|
+
let transport = fixture.transport();
|
|
527
|
+
let source_before = fs::read(&fixture.source_file).expect("source backing before fork");
|
|
528
|
+
#[cfg(unix)]
|
|
529
|
+
let source_inode = inode(&fixture.source_file);
|
|
530
|
+
let report = fixture
|
|
531
|
+
.fork(TARGET, None, &transport)
|
|
532
|
+
.expect("R02/R03: valid Pi session must fork instead of injecting into SOURCE");
|
|
533
|
+
assert_eq!(report.source_agent_id.as_str(), SOURCE);
|
|
534
|
+
assert_eq!(report.new_agent_id.as_str(), TARGET);
|
|
535
|
+
let new_session_id = report
|
|
536
|
+
.session_id
|
|
537
|
+
.as_ref()
|
|
538
|
+
.expect("fork session id")
|
|
539
|
+
.as_str();
|
|
540
|
+
assert_ne!(
|
|
541
|
+
new_session_id, SESSION_A,
|
|
542
|
+
"TARGET gets a fresh session UUID"
|
|
543
|
+
);
|
|
544
|
+
assert_eq!(
|
|
545
|
+
report.backing_state,
|
|
546
|
+
team_agent::lifecycle::ForkBackingState::Verified
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
let state = fixture.state();
|
|
550
|
+
let source = &state["agents"][SOURCE];
|
|
551
|
+
let target = &state["agents"][TARGET];
|
|
552
|
+
assert_eq!(
|
|
553
|
+
source["session_id"], SESSION_A,
|
|
554
|
+
"SOURCE tuple remains untouched"
|
|
555
|
+
);
|
|
556
|
+
assert_eq!(
|
|
557
|
+
source["rollout_path"],
|
|
558
|
+
fixture.source_file.to_string_lossy().as_ref()
|
|
559
|
+
);
|
|
560
|
+
assert_eq!(target["session_id"], new_session_id);
|
|
561
|
+
assert_eq!(target["provider"], "pi");
|
|
562
|
+
assert!(target["pane_id"].as_str().is_some());
|
|
563
|
+
assert_ne!(
|
|
564
|
+
target["pane_id"], source["pane_id"],
|
|
565
|
+
"new seat has its own pane"
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
let target_paths = fixture.target_paths(TARGET);
|
|
569
|
+
assert_ne!(
|
|
570
|
+
fs::canonicalize(&fixture.source_paths.runtime_root).expect("canonical source root"),
|
|
571
|
+
fs::canonicalize(&target_paths.runtime_root).expect("canonical target root"),
|
|
572
|
+
"source and target canonical seat roots differ"
|
|
573
|
+
);
|
|
574
|
+
let target_file = forked_target_file(&fixture, &state, TARGET);
|
|
575
|
+
assert!(target_file.starts_with(&target_paths.sessions));
|
|
576
|
+
assert_ne!(
|
|
577
|
+
fs::canonicalize(&fixture.source_file).expect("canonical source file"),
|
|
578
|
+
fs::canonicalize(&target_file).expect("canonical target file"),
|
|
579
|
+
"source and target backing paths differ"
|
|
580
|
+
);
|
|
581
|
+
#[cfg(unix)]
|
|
582
|
+
assert_ne!(
|
|
583
|
+
source_inode,
|
|
584
|
+
inode(&target_file),
|
|
585
|
+
"target backing must be a distinct inode, not a hard link"
|
|
586
|
+
);
|
|
587
|
+
assert_eq!(
|
|
588
|
+
fs::read(&fixture.source_file).expect("source remains"),
|
|
589
|
+
source_before
|
|
590
|
+
);
|
|
591
|
+
let header = read_header(&target_file);
|
|
592
|
+
assert_eq!(header["type"], "session");
|
|
593
|
+
assert_eq!(header["version"], 3);
|
|
594
|
+
assert_eq!(header["id"], new_session_id);
|
|
595
|
+
assert_ne!(header["id"], SESSION_A);
|
|
596
|
+
assert_eq!(
|
|
597
|
+
header["parentSession"],
|
|
598
|
+
fixture.source_file.to_string_lossy().as_ref(),
|
|
599
|
+
"new Pi header records the exact source backing as parentSession"
|
|
600
|
+
);
|
|
601
|
+
assert_eq!(
|
|
602
|
+
body_bytes(&target_file),
|
|
603
|
+
fixture.source_body,
|
|
604
|
+
"non-header bytes are exact"
|
|
605
|
+
);
|
|
606
|
+
assert_valid_entry_tree(&body_bytes(&target_file));
|
|
607
|
+
|
|
608
|
+
let entries: Vec<Value> = body_bytes(&target_file)
|
|
609
|
+
.split(|byte| *byte == b'\n')
|
|
610
|
+
.filter(|line| !line.is_empty())
|
|
611
|
+
.map(|line| serde_json::from_slice(line).expect("copied entry JSON"))
|
|
612
|
+
.collect();
|
|
613
|
+
assert!(
|
|
614
|
+
entries
|
|
615
|
+
.iter()
|
|
616
|
+
.any(|entry| entry["type"] == "tool_execution_end")
|
|
617
|
+
);
|
|
618
|
+
assert!(
|
|
619
|
+
entries
|
|
620
|
+
.iter()
|
|
621
|
+
.any(|entry| entry["customType"] == "context_edit")
|
|
622
|
+
);
|
|
623
|
+
assert!(entries.iter().any(|entry| entry["type"] == "compaction"));
|
|
624
|
+
let spawn_args = transport
|
|
625
|
+
.spawn_records()
|
|
626
|
+
.into_iter()
|
|
627
|
+
.flat_map(|(_, argv)| argv)
|
|
628
|
+
.collect::<Vec<_>>();
|
|
629
|
+
assert!(
|
|
630
|
+
spawn_args
|
|
631
|
+
.iter()
|
|
632
|
+
.any(|arg| arg.contains(target_file.to_string_lossy().as_ref())),
|
|
633
|
+
"new pane resumes only the new backing G; argv={spawn_args:?}"
|
|
634
|
+
);
|
|
635
|
+
assert!(
|
|
636
|
+
!spawn_args
|
|
637
|
+
.iter()
|
|
638
|
+
.any(|arg| arg.contains(fixture.source_file.to_string_lossy().as_ref())),
|
|
639
|
+
"new pane must not resume SOURCE backing F"
|
|
640
|
+
);
|
|
641
|
+
let source_pane = source["pane_id"].as_str().unwrap_or("");
|
|
642
|
+
assert!(
|
|
643
|
+
transport.inject_targets().iter().all(|target| {
|
|
644
|
+
source_pane.is_empty() || !format!("{target:?}").contains(source_pane)
|
|
645
|
+
}),
|
|
646
|
+
"SOURCE must not receive injected commands"
|
|
647
|
+
);
|
|
648
|
+
assert!(
|
|
649
|
+
!transport
|
|
650
|
+
.calls()
|
|
651
|
+
.iter()
|
|
652
|
+
.any(|call| call.contains("kill") || call.contains("restart")),
|
|
653
|
+
"SOURCE must not receive kill/restart during fork"
|
|
654
|
+
);
|
|
655
|
+
assert!(
|
|
656
|
+
target_paths.wrapper.is_file(),
|
|
657
|
+
"TARGET owns a fresh Pi wrapper"
|
|
658
|
+
);
|
|
659
|
+
let wrapper = fs::read_to_string(&target_paths.wrapper).expect("read target wrapper");
|
|
660
|
+
assert!(
|
|
661
|
+
wrapper.contains(TARGET),
|
|
662
|
+
"wrapper identity belongs to TARGET"
|
|
663
|
+
);
|
|
664
|
+
assert!(
|
|
665
|
+
source_pane.is_empty() || !wrapper.contains(source_pane),
|
|
666
|
+
"wrapper must not inherit SOURCE pane identity"
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[test]
|
|
671
|
+
#[serial(env)]
|
|
672
|
+
fn r04_target_materializes_the_latest_role_changing_only_identity_and_label() {
|
|
673
|
+
let fixture = Fixture::new();
|
|
674
|
+
let transport = fixture.transport();
|
|
675
|
+
fixture
|
|
676
|
+
.fork(TARGET, Some("Variant Researcher"), &transport)
|
|
677
|
+
.expect("R04: valid Pi source role must materialize on fork");
|
|
678
|
+
let state = fixture.state();
|
|
679
|
+
let source_path = PathBuf::from(
|
|
680
|
+
state["agents"][SOURCE]["dynamic_role_file"]
|
|
681
|
+
.as_str()
|
|
682
|
+
.unwrap(),
|
|
683
|
+
);
|
|
684
|
+
let target_path = PathBuf::from(
|
|
685
|
+
state["agents"][TARGET]["dynamic_role_file"]
|
|
686
|
+
.as_str()
|
|
687
|
+
.unwrap(),
|
|
688
|
+
);
|
|
689
|
+
let source_role = fs::read_to_string(source_path).expect("read latest source role");
|
|
690
|
+
let target_role = fs::read_to_string(target_path).expect("read materialized target role");
|
|
691
|
+
assert_eq!(role_field(&target_role, "name"), Some(TARGET));
|
|
692
|
+
assert_eq!(role_field(&target_role, "role"), Some("Variant Researcher"));
|
|
693
|
+
for field in [
|
|
694
|
+
"provider",
|
|
695
|
+
"model",
|
|
696
|
+
"auth_mode",
|
|
697
|
+
"effort",
|
|
698
|
+
"dangerously_skip_permissions",
|
|
699
|
+
"communication_mode",
|
|
700
|
+
"profile",
|
|
701
|
+
] {
|
|
702
|
+
assert_eq!(
|
|
703
|
+
role_field(&target_role, field),
|
|
704
|
+
role_field(&source_role, field),
|
|
705
|
+
"role field {field} is inherited"
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
assert!(
|
|
709
|
+
target_role.contains("mcp_team"),
|
|
710
|
+
"tools and permissions are preserved"
|
|
711
|
+
);
|
|
712
|
+
assert_eq!(
|
|
713
|
+
role_body(&target_role),
|
|
714
|
+
role_body(&source_role),
|
|
715
|
+
"role body bytes stay intact"
|
|
716
|
+
);
|
|
717
|
+
assert_eq!(state["agents"][TARGET]["provider"], "pi");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
#[test]
|
|
721
|
+
#[serial(env)]
|
|
722
|
+
fn r05_bad_source_tuples_and_jsonl_fail_closed_without_a_fresh_target() {
|
|
723
|
+
let fixture = Fixture::new();
|
|
724
|
+
let original_state = fixture.state();
|
|
725
|
+
let original_bytes = fs::read(&fixture.source_file).expect("source bytes");
|
|
726
|
+
let outside = fixture.root.join("outside.jsonl");
|
|
727
|
+
write_session(
|
|
728
|
+
&outside,
|
|
729
|
+
SESSION_A,
|
|
730
|
+
&fixture.run_workspace,
|
|
731
|
+
SESSION_TIMESTAMP,
|
|
732
|
+
&fixture.source_body,
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
let mut cases = vec![
|
|
736
|
+
"missing-tuple",
|
|
737
|
+
"wrong-header-id",
|
|
738
|
+
"wrong-cwd",
|
|
739
|
+
"wrong-version",
|
|
740
|
+
"wrong-timestamp",
|
|
741
|
+
"bad-json",
|
|
742
|
+
"broken-parent",
|
|
743
|
+
"truncated",
|
|
744
|
+
"truncated-json",
|
|
745
|
+
"outside-root",
|
|
746
|
+
];
|
|
747
|
+
#[cfg(unix)]
|
|
748
|
+
cases.push("symlink");
|
|
749
|
+
for case in cases {
|
|
750
|
+
let mut state = original_state.clone();
|
|
751
|
+
let source = state["agents"][SOURCE].as_object_mut().expect("source row");
|
|
752
|
+
let mut bytes = original_bytes.clone();
|
|
753
|
+
match case {
|
|
754
|
+
"missing-tuple" => {
|
|
755
|
+
source.remove("rollout_path");
|
|
756
|
+
}
|
|
757
|
+
"wrong-header-id" => {
|
|
758
|
+
write_session(
|
|
759
|
+
&fixture.source_file,
|
|
760
|
+
"cc09d133-878f-4241-920f-f7ebfefc2a9e",
|
|
761
|
+
&fixture.run_workspace,
|
|
762
|
+
SESSION_TIMESTAMP,
|
|
763
|
+
&fixture.source_body,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
"wrong-cwd" => {
|
|
767
|
+
let wrong_cwd = fixture.root.join("wrong-cwd");
|
|
768
|
+
write_session(
|
|
769
|
+
&fixture.source_file,
|
|
770
|
+
SESSION_A,
|
|
771
|
+
&wrong_cwd,
|
|
772
|
+
SESSION_TIMESTAMP,
|
|
773
|
+
&fixture.source_body,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
"wrong-version" => {
|
|
777
|
+
let header = json!({"type":"session","version":2,"id":SESSION_A,"timestamp":SESSION_TIMESTAMP,"cwd":fixture.run_workspace.to_string_lossy()});
|
|
778
|
+
bytes = [header.to_string().as_bytes(), b"\n", &fixture.source_body].concat();
|
|
779
|
+
fs::write(&fixture.source_file, &bytes).expect("write wrong-version session");
|
|
780
|
+
}
|
|
781
|
+
"wrong-timestamp" => {
|
|
782
|
+
let header = json!({"type":"session","version":3,"id":SESSION_A,"timestamp":"not-a-timestamp","cwd":fixture.run_workspace.to_string_lossy()});
|
|
783
|
+
bytes = [header.to_string().as_bytes(), b"\n", &fixture.source_body].concat();
|
|
784
|
+
fs::write(&fixture.source_file, &bytes).expect("write invalid-timestamp session");
|
|
785
|
+
}
|
|
786
|
+
"bad-json" => {
|
|
787
|
+
bytes = [b"{not-json}\n".as_slice(), &fixture.source_body].concat();
|
|
788
|
+
fs::write(&fixture.source_file, &bytes).expect("write malformed source");
|
|
789
|
+
}
|
|
790
|
+
"broken-parent" => {
|
|
791
|
+
let invalid = json!({"type":"message","id":"orphan","parentId":"missing-entry","message":{"role":"user","content":[]}}).to_string();
|
|
792
|
+
bytes = [serde_json::to_string(&json!({"type":"session","version":3,"id":SESSION_A,"timestamp":SESSION_TIMESTAMP,"cwd":fixture.run_workspace.to_string_lossy()})).unwrap().as_bytes(), b"\n", invalid.as_bytes(), b"\n"].concat();
|
|
793
|
+
fs::write(&fixture.source_file, &bytes).expect("write invalid entry tree");
|
|
794
|
+
}
|
|
795
|
+
"truncated" => {
|
|
796
|
+
bytes.pop();
|
|
797
|
+
fs::write(&fixture.source_file, &bytes).expect("truncate final JSONL newline");
|
|
798
|
+
}
|
|
799
|
+
"truncated-json" => {
|
|
800
|
+
let last_line = bytes[..bytes.len() - 1]
|
|
801
|
+
.iter()
|
|
802
|
+
.rposition(|byte| *byte == b'\n')
|
|
803
|
+
.expect("last entry begins after a newline")
|
|
804
|
+
+ 1;
|
|
805
|
+
bytes.truncate(last_line + 3);
|
|
806
|
+
fs::write(&fixture.source_file, &bytes).expect("truncate inside final JSON entry");
|
|
807
|
+
}
|
|
808
|
+
"outside-root" => {
|
|
809
|
+
source.insert(
|
|
810
|
+
"rollout_path".to_string(),
|
|
811
|
+
json!(outside.to_string_lossy().to_string()),
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
#[cfg(unix)]
|
|
815
|
+
"symlink" => {
|
|
816
|
+
fs::remove_file(&fixture.source_file).expect("remove source for symlink case");
|
|
817
|
+
std::os::unix::fs::symlink(&outside, &fixture.source_file)
|
|
818
|
+
.expect("create source symlink");
|
|
819
|
+
}
|
|
820
|
+
_ => unreachable!(),
|
|
821
|
+
}
|
|
822
|
+
fixture.save_state(&state);
|
|
823
|
+
let transport = fixture.transport();
|
|
824
|
+
let result = fixture.fork(TARGET, None, &transport);
|
|
825
|
+
assert!(
|
|
826
|
+
result.is_err(),
|
|
827
|
+
"R05 ({case}): invalid source must refuse, never fresh-fork; result={result:?}"
|
|
828
|
+
);
|
|
829
|
+
fixture.assert_no_target(TARGET, &transport);
|
|
830
|
+
let after = fixture.state();
|
|
831
|
+
assert!(
|
|
832
|
+
after["agents"].get(TARGET).is_none(),
|
|
833
|
+
"R05 ({case}): no target roster row"
|
|
834
|
+
);
|
|
835
|
+
if case != "missing-tuple" && case != "outside-root" {
|
|
836
|
+
let source_after = &after["agents"][SOURCE];
|
|
837
|
+
assert_eq!(
|
|
838
|
+
source_after["session_id"], SESSION_A,
|
|
839
|
+
"source identity remains unchanged"
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if case == "symlink" {
|
|
843
|
+
fs::remove_file(&fixture.source_file).expect("remove source symlink");
|
|
844
|
+
}
|
|
845
|
+
fs::write(&fixture.source_file, &original_bytes)
|
|
846
|
+
.expect("restore valid source between adversarial cases");
|
|
847
|
+
}
|
|
848
|
+
fixture.save_state(&original_state);
|
|
849
|
+
fixture.assert_valid_source_fixture();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
#[test]
|
|
853
|
+
#[serial(env)]
|
|
854
|
+
fn r06_target_collisions_preserve_existing_roster_spec_and_resource_bytes() {
|
|
855
|
+
let fixture = Fixture::new();
|
|
856
|
+
let mut state = fixture.state();
|
|
857
|
+
state["agents"][TARGET] = json!({"status":"stopped","provider":"pi","sentinel":"must-survive"});
|
|
858
|
+
fixture.save_state(&state);
|
|
859
|
+
let roster_before = fs::read(&fixture.state_path).expect("persisted roster bytes");
|
|
860
|
+
let selected = team_agent::state::selector::resolve_active_team(
|
|
861
|
+
&fixture.team_dir,
|
|
862
|
+
Some(&fixture.team_key),
|
|
863
|
+
team_agent::state::selector::SelectorMode::RequireSpec,
|
|
864
|
+
)
|
|
865
|
+
.expect("selected team");
|
|
866
|
+
let spec_path = selected.spec_path.expect("compiled spec path");
|
|
867
|
+
let spec_before = fs::read(&spec_path).expect("spec bytes");
|
|
868
|
+
let target_paths = fixture.target_paths(TARGET);
|
|
869
|
+
fs::create_dir_all(&target_paths.sessions).expect("pre-existing target resource");
|
|
870
|
+
let sentinel = target_paths.sessions.join("keep.jsonl");
|
|
871
|
+
fs::write(&sentinel, b"collision bytes\n").expect("write collision sentinel");
|
|
872
|
+
let transport = fixture.transport();
|
|
873
|
+
assert!(
|
|
874
|
+
fixture.fork(TARGET, None, &transport).is_err(),
|
|
875
|
+
"R06 duplicate target must refuse"
|
|
876
|
+
);
|
|
877
|
+
assert_eq!(
|
|
878
|
+
fs::read(&fixture.state_path).unwrap(),
|
|
879
|
+
roster_before,
|
|
880
|
+
"roster bytes stay unchanged"
|
|
881
|
+
);
|
|
882
|
+
assert_eq!(
|
|
883
|
+
fs::read(&spec_path).unwrap(),
|
|
884
|
+
spec_before,
|
|
885
|
+
"compiled spec bytes stay unchanged"
|
|
886
|
+
);
|
|
887
|
+
assert_eq!(
|
|
888
|
+
fs::read(sentinel).unwrap(),
|
|
889
|
+
b"collision bytes\n",
|
|
890
|
+
"existing target bytes are never overwritten"
|
|
891
|
+
);
|
|
892
|
+
assert!(
|
|
893
|
+
transport.spawn_records().is_empty(),
|
|
894
|
+
"collision must not spawn a second target"
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
#[test]
|
|
899
|
+
#[serial(env)]
|
|
900
|
+
fn r07_capture_observation_does_not_replace_the_inherited_target_tuple() {
|
|
901
|
+
let fixture = Fixture::new();
|
|
902
|
+
let transport = fixture.transport();
|
|
903
|
+
fixture.fork(TARGET, None, &transport).expect(
|
|
904
|
+
"R07: source header predates spawn, but the captured source tuple is authoritative",
|
|
905
|
+
);
|
|
906
|
+
let mut state = fixture.state();
|
|
907
|
+
let target_before = state["agents"][TARGET].clone();
|
|
908
|
+
let mut adapter_for = |provider| get_adapter(provider);
|
|
909
|
+
let report = team_agent::provider::session::capture::capture_missing_provider_sessions_once(
|
|
910
|
+
&mut state,
|
|
911
|
+
&mut adapter_for,
|
|
912
|
+
true,
|
|
913
|
+
1,
|
|
914
|
+
)
|
|
915
|
+
.expect("coordinator observation merge");
|
|
916
|
+
assert!(
|
|
917
|
+
report.assigned.is_empty(),
|
|
918
|
+
"complete inherited tuples are not recaptured: {report:?}"
|
|
919
|
+
);
|
|
920
|
+
assert_eq!(
|
|
921
|
+
state["agents"][TARGET]["session_id"],
|
|
922
|
+
target_before["session_id"]
|
|
923
|
+
);
|
|
924
|
+
assert_eq!(
|
|
925
|
+
state["agents"][TARGET]["rollout_path"],
|
|
926
|
+
target_before["rollout_path"]
|
|
927
|
+
);
|
|
928
|
+
fixture.save_state(&state);
|
|
929
|
+
let reloaded = fixture.state();
|
|
930
|
+
assert_eq!(
|
|
931
|
+
reloaded["agents"][TARGET]["session_id"], target_before["session_id"],
|
|
932
|
+
"persisted observation merge keeps inherited session B"
|
|
933
|
+
);
|
|
934
|
+
assert_eq!(
|
|
935
|
+
reloaded["agents"][TARGET]["rollout_path"], target_before["rollout_path"],
|
|
936
|
+
"persisted observation merge keeps backing G"
|
|
937
|
+
);
|
|
938
|
+
assert_eq!(reloaded["agents"][SOURCE]["session_id"], SESSION_A);
|
|
939
|
+
assert_eq!(
|
|
940
|
+
reloaded["agents"][SOURCE]["rollout_path"],
|
|
941
|
+
fixture.source_file.to_string_lossy().as_ref()
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
#[test]
|
|
946
|
+
#[serial(env)]
|
|
947
|
+
fn r08_sibling_observation_racing_with_target_spawn_is_not_lost() {
|
|
948
|
+
let fixture = Fixture::new();
|
|
949
|
+
let mut observed = fixture.state();
|
|
950
|
+
observed["agents"]["sibling"] = json!({
|
|
951
|
+
"provider":"pi", "status":"running", "session_id":"sibling-session",
|
|
952
|
+
"rollout_path":"/sibling/only.jsonl", "captured_at":"2026-09-12T10:01:00Z",
|
|
953
|
+
"captured_via":"session_scan", "observation_generation":"concurrent-observation-17"
|
|
954
|
+
});
|
|
955
|
+
let transport = fixture.transport();
|
|
956
|
+
let observed_transport = transport.clone();
|
|
957
|
+
let run_workspace = fixture.run_workspace.clone();
|
|
958
|
+
let updater = std::thread::spawn(move || {
|
|
959
|
+
let deadline = Instant::now() + Duration::from_secs(5);
|
|
960
|
+
while !observed_transport
|
|
961
|
+
.calls()
|
|
962
|
+
.iter()
|
|
963
|
+
.any(|call| call.starts_with("spawn"))
|
|
964
|
+
{
|
|
965
|
+
if Instant::now() >= deadline {
|
|
966
|
+
return Err("valid Pi fork never reached target spawn".to_string());
|
|
967
|
+
}
|
|
968
|
+
std::thread::yield_now();
|
|
969
|
+
}
|
|
970
|
+
team_agent::state::projection::save_team_scoped_state(&run_workspace, &observed)
|
|
971
|
+
.map_err(|error| format!("persist concurrent sibling observation: {error}"))
|
|
972
|
+
});
|
|
973
|
+
let fork_result = fixture.fork(TARGET, None, &transport);
|
|
974
|
+
let update_result = updater.join().expect("sibling observation thread");
|
|
975
|
+
assert!(
|
|
976
|
+
update_result.is_ok(),
|
|
977
|
+
"R08 concurrent state update must commit while target spawn is in flight; fork={fork_result:?} update={update_result:?}"
|
|
978
|
+
);
|
|
979
|
+
let fork_report =
|
|
980
|
+
fork_result.expect("R08 Pi fork must complete after the concurrent sibling observation");
|
|
981
|
+
assert_eq!(fork_report.new_agent_id.as_str(), TARGET);
|
|
982
|
+
let after = fixture.state();
|
|
983
|
+
assert_eq!(
|
|
984
|
+
after["agents"]["sibling"]["observation_generation"], "concurrent-observation-17",
|
|
985
|
+
"fork must merge its target row, not roll back an observation concurrent with target spawn"
|
|
986
|
+
);
|
|
987
|
+
assert_eq!(after["agents"][SOURCE]["session_id"], SESSION_A);
|
|
988
|
+
assert!(after["agents"].get(TARGET).is_some());
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
#[test]
|
|
992
|
+
#[serial(env)]
|
|
993
|
+
fn r09_spawn_failure_is_compensated_without_an_alive_target_on_deleted_backing() {
|
|
994
|
+
let fixture = Fixture::new();
|
|
995
|
+
let transport = fixture
|
|
996
|
+
.transport()
|
|
997
|
+
.with_spawn_failure(TARGET, "R09 injected target spawn failure");
|
|
998
|
+
let result = fixture.fork(TARGET, None, &transport);
|
|
999
|
+
assert!(
|
|
1000
|
+
result.is_err(),
|
|
1001
|
+
"R09 injected spawn failure must be reported: {result:?}"
|
|
1002
|
+
);
|
|
1003
|
+
assert!(
|
|
1004
|
+
transport.calls().iter().any(|call| call.contains("spawn")),
|
|
1005
|
+
"R09 setup must reach the existing transport spawn fault-injection seam; calls={:?}",
|
|
1006
|
+
transport.calls()
|
|
1007
|
+
);
|
|
1008
|
+
let state = fixture.state();
|
|
1009
|
+
assert!(
|
|
1010
|
+
!state["agents"]
|
|
1011
|
+
.get(TARGET)
|
|
1012
|
+
.is_some_and(|target| target["status"] == "running"),
|
|
1013
|
+
"failed target spawn cannot be reported as alive; state={state}"
|
|
1014
|
+
);
|
|
1015
|
+
let target_paths = fixture.target_paths(TARGET);
|
|
1016
|
+
if !target_paths.sessions.exists() {
|
|
1017
|
+
assert!(
|
|
1018
|
+
!target_paths.wrapper.exists(),
|
|
1019
|
+
"fully rolled-back failure leaves no wrapper"
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
assert_eq!(state["agents"][SOURCE]["session_id"], SESSION_A);
|
|
1023
|
+
assert_eq!(
|
|
1024
|
+
state["agents"][SOURCE]["rollout_path"],
|
|
1025
|
+
fixture.source_file.to_string_lossy().as_ref()
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
#[test]
|
|
1030
|
+
#[serial(env)]
|
|
1031
|
+
fn r09_pane_verification_failure_compensates_the_new_target() {
|
|
1032
|
+
let fixture = Fixture::new();
|
|
1033
|
+
let transport = fixture.transport().with_spawned_panes_addressable(false);
|
|
1034
|
+
let result = fixture.fork(TARGET, None, &transport);
|
|
1035
|
+
assert!(
|
|
1036
|
+
result.is_err(),
|
|
1037
|
+
"R09 pane verification failure must be reported: {result:?}"
|
|
1038
|
+
);
|
|
1039
|
+
assert!(
|
|
1040
|
+
transport
|
|
1041
|
+
.calls()
|
|
1042
|
+
.iter()
|
|
1043
|
+
.any(|call| call.starts_with("spawn")),
|
|
1044
|
+
"R09 setup must reach the transport spawn seam before pane verification; calls={:?}",
|
|
1045
|
+
transport.calls()
|
|
1046
|
+
);
|
|
1047
|
+
assert!(
|
|
1048
|
+
transport.calls().contains(&"kill_pane"),
|
|
1049
|
+
"R09 must compensate the spawned target pane after verification fails; calls={:?}",
|
|
1050
|
+
transport.calls()
|
|
1051
|
+
);
|
|
1052
|
+
let state = fixture.state();
|
|
1053
|
+
assert!(
|
|
1054
|
+
!state["agents"]
|
|
1055
|
+
.get(TARGET)
|
|
1056
|
+
.is_some_and(|target| target["status"] == "running"),
|
|
1057
|
+
"unverified target cannot remain alive in runtime state; state={state}"
|
|
1058
|
+
);
|
|
1059
|
+
let target_paths = fixture.target_paths(TARGET);
|
|
1060
|
+
assert!(
|
|
1061
|
+
!target_paths.wrapper.exists(),
|
|
1062
|
+
"failed target must not retain a runnable Pi wrapper"
|
|
1063
|
+
);
|
|
1064
|
+
assert_eq!(state["agents"][SOURCE]["session_id"], SESSION_A);
|
|
1065
|
+
assert_eq!(
|
|
1066
|
+
state["agents"][SOURCE]["rollout_path"],
|
|
1067
|
+
fixture.source_file.to_string_lossy().as_ref()
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
#[test]
|
|
1072
|
+
#[serial(env)]
|
|
1073
|
+
fn r10_messages_remain_team_scoped_and_target_messages_name_only_target() {
|
|
1074
|
+
let fixture = Fixture::new();
|
|
1075
|
+
let store = team_agent::db::message_store::MessageStore::open(&fixture.run_workspace)
|
|
1076
|
+
.expect("open selected team's message database");
|
|
1077
|
+
let source_marker = store
|
|
1078
|
+
.create_message_with_id(
|
|
1079
|
+
"msg_fork_source_keep",
|
|
1080
|
+
None,
|
|
1081
|
+
"caller",
|
|
1082
|
+
SOURCE,
|
|
1083
|
+
"source-only",
|
|
1084
|
+
None,
|
|
1085
|
+
false,
|
|
1086
|
+
Some(&fixture.team_key),
|
|
1087
|
+
)
|
|
1088
|
+
.expect("seed source message marker");
|
|
1089
|
+
let sibling_marker = store
|
|
1090
|
+
.create_message_with_id(
|
|
1091
|
+
"msg_fork_sibling_keep",
|
|
1092
|
+
None,
|
|
1093
|
+
"caller",
|
|
1094
|
+
"sibling",
|
|
1095
|
+
"sibling-only",
|
|
1096
|
+
None,
|
|
1097
|
+
false,
|
|
1098
|
+
Some("sibling-team"),
|
|
1099
|
+
)
|
|
1100
|
+
.expect("seed sibling-team message marker");
|
|
1101
|
+
let transport = fixture.transport();
|
|
1102
|
+
fixture
|
|
1103
|
+
.fork(TARGET, None, &transport)
|
|
1104
|
+
.expect("R10 database ownership checks require a successfully registered target");
|
|
1105
|
+
let target_marker = store
|
|
1106
|
+
.create_message_with_id(
|
|
1107
|
+
"msg_fork_target_owner",
|
|
1108
|
+
None,
|
|
1109
|
+
SOURCE,
|
|
1110
|
+
TARGET,
|
|
1111
|
+
"target-only",
|
|
1112
|
+
None,
|
|
1113
|
+
false,
|
|
1114
|
+
Some(&fixture.team_key),
|
|
1115
|
+
)
|
|
1116
|
+
.expect("target message is stored in selected team DB");
|
|
1117
|
+
let connection = team_agent::db::schema::open_db(store.db_path()).expect("read team DB");
|
|
1118
|
+
for (id, owner, recipient) in [
|
|
1119
|
+
(&source_marker, fixture.team_key.as_str(), SOURCE),
|
|
1120
|
+
(&sibling_marker, "sibling-team", "sibling"),
|
|
1121
|
+
(&target_marker, fixture.team_key.as_str(), TARGET),
|
|
1122
|
+
] {
|
|
1123
|
+
let actual: (Option<String>, String) = connection
|
|
1124
|
+
.query_row(
|
|
1125
|
+
"select owner_team_id, recipient from messages where message_id=?1",
|
|
1126
|
+
[id],
|
|
1127
|
+
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
1128
|
+
)
|
|
1129
|
+
.expect("message row exists");
|
|
1130
|
+
assert_eq!(
|
|
1131
|
+
actual.0.as_deref(),
|
|
1132
|
+
Some(owner),
|
|
1133
|
+
"message {id} has its owner team"
|
|
1134
|
+
);
|
|
1135
|
+
assert_eq!(
|
|
1136
|
+
actual.1, recipient,
|
|
1137
|
+
"message {id} remains attributed to its recipient"
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
#[test]
|
|
1143
|
+
#[serial(env)]
|
|
1144
|
+
fn r11_source_and_target_backings_remain_independently_loadable() {
|
|
1145
|
+
let fixture = Fixture::new();
|
|
1146
|
+
let transport = fixture.transport();
|
|
1147
|
+
fixture
|
|
1148
|
+
.fork(TARGET, None, &transport)
|
|
1149
|
+
.expect("R11 independent lifecycle requires a successful fork");
|
|
1150
|
+
let state = fixture.state();
|
|
1151
|
+
let target_file = forked_target_file(&fixture, &state, TARGET);
|
|
1152
|
+
append_session_entry(
|
|
1153
|
+
&fixture.source_file,
|
|
1154
|
+
json!({
|
|
1155
|
+
"type":"message", "id":"entry-source-live", "parentId":"entry-user",
|
|
1156
|
+
"timestamp":"2026-09-12T10:02:00Z",
|
|
1157
|
+
"message":{"role":"assistant","content":[{"type":"text","text":"SOURCE-only follow-up"}]}
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
1160
|
+
append_session_entry(
|
|
1161
|
+
&target_file,
|
|
1162
|
+
json!({
|
|
1163
|
+
"type":"message", "id":"entry-target-live", "parentId":"entry-user",
|
|
1164
|
+
"timestamp":"2026-09-12T10:03:00Z",
|
|
1165
|
+
"message":{"role":"assistant","content":[{"type":"text","text":"TARGET-only follow-up"}]}
|
|
1166
|
+
}),
|
|
1167
|
+
);
|
|
1168
|
+
let target_before =
|
|
1169
|
+
fs::read(&target_file).expect("target backing bytes after target-only append");
|
|
1170
|
+
let source_before =
|
|
1171
|
+
fs::read(&fixture.source_file).expect("source backing after source-only append");
|
|
1172
|
+
assert!(
|
|
1173
|
+
source_before
|
|
1174
|
+
.windows(b"SOURCE-only follow-up".len())
|
|
1175
|
+
.any(|bytes| bytes == b"SOURCE-only follow-up")
|
|
1176
|
+
);
|
|
1177
|
+
assert!(
|
|
1178
|
+
!source_before
|
|
1179
|
+
.windows(b"TARGET-only follow-up".len())
|
|
1180
|
+
.any(|bytes| bytes == b"TARGET-only follow-up")
|
|
1181
|
+
);
|
|
1182
|
+
assert!(
|
|
1183
|
+
target_before
|
|
1184
|
+
.windows(b"TARGET-only follow-up".len())
|
|
1185
|
+
.any(|bytes| bytes == b"TARGET-only follow-up")
|
|
1186
|
+
);
|
|
1187
|
+
assert!(
|
|
1188
|
+
!target_before
|
|
1189
|
+
.windows(b"SOURCE-only follow-up".len())
|
|
1190
|
+
.any(|bytes| bytes == b"SOURCE-only follow-up")
|
|
1191
|
+
);
|
|
1192
|
+
assert_valid_entry_tree(&body_bytes(&fixture.source_file));
|
|
1193
|
+
assert_valid_entry_tree(&body_bytes(&target_file));
|
|
1194
|
+
fs::remove_file(&fixture.source_file).expect("simulate SOURCE backing retirement");
|
|
1195
|
+
assert_eq!(
|
|
1196
|
+
fs::read(&target_file).expect("TARGET remains offline-loadable"),
|
|
1197
|
+
target_before
|
|
1198
|
+
);
|
|
1199
|
+
fs::write(&fixture.source_file, &source_before).expect("restore independent SOURCE backing");
|
|
1200
|
+
fs::remove_file(&target_file).expect("simulate TARGET backing retirement");
|
|
1201
|
+
assert!(
|
|
1202
|
+
fixture.source_file.is_file(),
|
|
1203
|
+
"removing TARGET backing must not remove SOURCE backing"
|
|
1204
|
+
);
|
|
1205
|
+
assert_eq!(fs::read(&fixture.source_file).unwrap(), source_before);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
#[test]
|
|
1209
|
+
#[serial(env)]
|
|
1210
|
+
fn r12_pi_clone_and_non_pi_in_window_fork_compatibility_are_preserved() {
|
|
1211
|
+
let workspace = Path::new("/workspace/pi-fork-contract");
|
|
1212
|
+
let source_paths = pi_seat_paths(workspace, "team-a", SOURCE);
|
|
1213
|
+
let clone_paths = pi_seat_paths(workspace, "team-a", TARGET);
|
|
1214
|
+
assert_ne!(source_paths.runtime_root, clone_paths.runtime_root);
|
|
1215
|
+
assert_ne!(source_paths.wrapper, clone_paths.wrapper);
|
|
1216
|
+
assert_ne!(source_paths.sessions, clone_paths.sessions);
|
|
1217
|
+
assert!(
|
|
1218
|
+
matches!(
|
|
1219
|
+
get_adapter(Provider::Pi).fork(
|
|
1220
|
+
Some(&SessionId::new(SESSION_A)),
|
|
1221
|
+
AuthMode::Subscription,
|
|
1222
|
+
None
|
|
1223
|
+
),
|
|
1224
|
+
Err(team_agent::provider::ProviderError::CapabilityUnsupported(
|
|
1225
|
+
_
|
|
1226
|
+
))
|
|
1227
|
+
),
|
|
1228
|
+
"managed fork must not be faked by changing the provider's unsupported native-fork capability"
|
|
1229
|
+
);
|
|
1230
|
+
assert_eq!(
|
|
1231
|
+
team_agent::lifecycle::launch::in_window_fork(Provider::Grok, AuthMode::Subscription)
|
|
1232
|
+
.map(|spec| spec.command),
|
|
1233
|
+
Some("/fork"),
|
|
1234
|
+
"non-Pi Grok subscription keeps its verified in-window command"
|
|
1235
|
+
);
|
|
1236
|
+
assert_eq!(
|
|
1237
|
+
team_agent::lifecycle::launch::in_window_fork(Provider::ClaudeCode, AuthMode::Subscription)
|
|
1238
|
+
.map(|spec| spec.command),
|
|
1239
|
+
Some("/branch"),
|
|
1240
|
+
"non-Pi Claude subscription keeps its verified in-window command"
|
|
1241
|
+
);
|
|
1242
|
+
assert!(
|
|
1243
|
+
team_agent::lifecycle::launch::in_window_fork(Provider::Pi, AuthMode::Subscription)
|
|
1244
|
+
.is_none(),
|
|
1245
|
+
"Pi must use managed new-seat inheritance, never an in-window slash alias"
|
|
1246
|
+
);
|
|
1247
|
+
let fixture = Fixture::new();
|
|
1248
|
+
let state = fixture.state();
|
|
1249
|
+
assert_eq!(state["agents"][SOURCE]["session_id"], SESSION_A);
|
|
1250
|
+
assert!(
|
|
1251
|
+
fixture
|
|
1252
|
+
.source_paths
|
|
1253
|
+
.sessions
|
|
1254
|
+
.starts_with(&fixture.source_paths.runtime_root)
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
#[test]
|
|
1259
|
+
#[serial(env)]
|
|
1260
|
+
fn r13_cascading_fork_inherits_variant1_hello_and_reply_without_aliasing_any_seat() {
|
|
1261
|
+
const VARIANT1: &str = "variant1";
|
|
1262
|
+
const VARIANT2: &str = "variant2";
|
|
1263
|
+
|
|
1264
|
+
let fixture = Fixture::new();
|
|
1265
|
+
let author_before = fs::read(&fixture.source_file).expect("author session before forks");
|
|
1266
|
+
let transport = fixture.transport().with_windows(vec![
|
|
1267
|
+
team_agent::transport::WindowName::new(SOURCE),
|
|
1268
|
+
team_agent::transport::WindowName::new(VARIANT1),
|
|
1269
|
+
team_agent::transport::WindowName::new(VARIANT2),
|
|
1270
|
+
]);
|
|
1271
|
+
|
|
1272
|
+
let first_report = fixture
|
|
1273
|
+
.fork_from(SOURCE, VARIANT1, None, &transport)
|
|
1274
|
+
.expect("R13 first generation fork creates variant1");
|
|
1275
|
+
let after_first = fixture.state();
|
|
1276
|
+
let session_b = after_first["agents"][VARIANT1]["session_id"]
|
|
1277
|
+
.as_str()
|
|
1278
|
+
.expect("variant1 session B")
|
|
1279
|
+
.to_string();
|
|
1280
|
+
assert_eq!(
|
|
1281
|
+
first_report.session_id.as_ref().map(SessionId::as_str),
|
|
1282
|
+
Some(session_b.as_str())
|
|
1283
|
+
);
|
|
1284
|
+
assert_ne!(session_b, SESSION_A);
|
|
1285
|
+
let variant1_file = forked_target_file(&fixture, &after_first, VARIANT1);
|
|
1286
|
+
assert_eq!(read_header(&variant1_file)["id"], session_b);
|
|
1287
|
+
assert_eq!(
|
|
1288
|
+
read_header(&variant1_file)["parentSession"],
|
|
1289
|
+
fixture.source_file.to_string_lossy().as_ref()
|
|
1290
|
+
);
|
|
1291
|
+
assert_eq!(body_bytes(&variant1_file), fixture.source_body);
|
|
1292
|
+
|
|
1293
|
+
append_session_entry(
|
|
1294
|
+
&variant1_file,
|
|
1295
|
+
json!({
|
|
1296
|
+
"type":"message", "id":"entry-variant1-hello", "parentId":"entry-user",
|
|
1297
|
+
"timestamp":"2026-09-12T10:10:00Z",
|
|
1298
|
+
"message":{"role":"user","content":[{"type":"text","text":"HELLO"}]}
|
|
1299
|
+
}),
|
|
1300
|
+
);
|
|
1301
|
+
append_session_entry(
|
|
1302
|
+
&variant1_file,
|
|
1303
|
+
json!({
|
|
1304
|
+
"type":"message", "id":"entry-variant1-reply", "parentId":"entry-variant1-hello",
|
|
1305
|
+
"timestamp":"2026-09-12T10:10:01Z",
|
|
1306
|
+
"message":{"role":"assistant","content":[{"type":"text","text":"HELLO acknowledged by variant1"}]}
|
|
1307
|
+
}),
|
|
1308
|
+
);
|
|
1309
|
+
let variant1_history = body_bytes(&variant1_file);
|
|
1310
|
+
assert!(variant1_history.starts_with(&fixture.source_body));
|
|
1311
|
+
assert!(
|
|
1312
|
+
variant1_history
|
|
1313
|
+
.windows(b"HELLO".len())
|
|
1314
|
+
.any(|bytes| bytes == b"HELLO")
|
|
1315
|
+
);
|
|
1316
|
+
assert!(
|
|
1317
|
+
variant1_history
|
|
1318
|
+
.windows(b"HELLO acknowledged by variant1".len())
|
|
1319
|
+
.any(|bytes| bytes == b"HELLO acknowledged by variant1")
|
|
1320
|
+
);
|
|
1321
|
+
assert_valid_entry_tree(&variant1_history);
|
|
1322
|
+
let variant1_before_second_fork =
|
|
1323
|
+
fs::read(&variant1_file).expect("variant1 session with its new dialogue");
|
|
1324
|
+
|
|
1325
|
+
let second_report = fixture
|
|
1326
|
+
.fork_from(VARIANT1, VARIANT2, None, &transport)
|
|
1327
|
+
.expect("R13 second generation fork creates variant2 from variant1");
|
|
1328
|
+
let after_second = fixture.state();
|
|
1329
|
+
let session_c = after_second["agents"][VARIANT2]["session_id"]
|
|
1330
|
+
.as_str()
|
|
1331
|
+
.expect("variant2 session C");
|
|
1332
|
+
assert_ne!(session_c, SESSION_A);
|
|
1333
|
+
assert_ne!(session_c, session_b);
|
|
1334
|
+
assert_eq!(
|
|
1335
|
+
second_report.session_id.as_ref().map(SessionId::as_str),
|
|
1336
|
+
Some(session_c)
|
|
1337
|
+
);
|
|
1338
|
+
assert_eq!(after_second["agents"][SOURCE]["session_id"], SESSION_A);
|
|
1339
|
+
assert_eq!(after_second["agents"][VARIANT1]["session_id"], session_b);
|
|
1340
|
+
|
|
1341
|
+
let variant2_file = forked_target_file(&fixture, &after_second, VARIANT2);
|
|
1342
|
+
let variant1_header = read_header(&variant1_file);
|
|
1343
|
+
let variant2_header = read_header(&variant2_file);
|
|
1344
|
+
assert_eq!(variant1_header["id"], session_b);
|
|
1345
|
+
assert_eq!(variant2_header["id"], session_c);
|
|
1346
|
+
assert_eq!(
|
|
1347
|
+
variant2_header["parentSession"],
|
|
1348
|
+
variant1_file.to_string_lossy().as_ref(),
|
|
1349
|
+
"variant2 parentSession must identify its immediate source, variant1"
|
|
1350
|
+
);
|
|
1351
|
+
assert_eq!(
|
|
1352
|
+
body_bytes(&variant2_file),
|
|
1353
|
+
variant1_history,
|
|
1354
|
+
"variant2 keeps the complete original history and variant1's HELLO/reply bytes"
|
|
1355
|
+
);
|
|
1356
|
+
assert_eq!(
|
|
1357
|
+
fs::read(&fixture.source_file).expect("author history remains unchanged"),
|
|
1358
|
+
author_before
|
|
1359
|
+
);
|
|
1360
|
+
assert_eq!(
|
|
1361
|
+
fs::read(&variant1_file).expect("variant1 history remains unchanged by second fork"),
|
|
1362
|
+
variant1_before_second_fork
|
|
1363
|
+
);
|
|
1364
|
+
assert_valid_entry_tree(&body_bytes(&variant2_file));
|
|
1365
|
+
|
|
1366
|
+
let variant1_paths = fixture.target_paths(VARIANT1);
|
|
1367
|
+
let variant2_paths = fixture.target_paths(VARIANT2);
|
|
1368
|
+
let seat_roots = [
|
|
1369
|
+
fs::canonicalize(&fixture.source_paths.runtime_root).expect("author seat root"),
|
|
1370
|
+
fs::canonicalize(&variant1_paths.runtime_root).expect("variant1 seat root"),
|
|
1371
|
+
fs::canonicalize(&variant2_paths.runtime_root).expect("variant2 seat root"),
|
|
1372
|
+
];
|
|
1373
|
+
assert_ne!(seat_roots[0], seat_roots[1]);
|
|
1374
|
+
assert_ne!(seat_roots[0], seat_roots[2]);
|
|
1375
|
+
assert_ne!(seat_roots[1], seat_roots[2]);
|
|
1376
|
+
let session_paths = [
|
|
1377
|
+
fs::canonicalize(&fixture.source_file).expect("author session path"),
|
|
1378
|
+
fs::canonicalize(&variant1_file).expect("variant1 session path"),
|
|
1379
|
+
fs::canonicalize(&variant2_file).expect("variant2 session path"),
|
|
1380
|
+
];
|
|
1381
|
+
assert_ne!(session_paths[0], session_paths[1]);
|
|
1382
|
+
assert_ne!(session_paths[0], session_paths[2]);
|
|
1383
|
+
assert_ne!(session_paths[1], session_paths[2]);
|
|
1384
|
+
#[cfg(unix)]
|
|
1385
|
+
{
|
|
1386
|
+
let inodes = [
|
|
1387
|
+
inode(&fixture.source_file),
|
|
1388
|
+
inode(&variant1_file),
|
|
1389
|
+
inode(&variant2_file),
|
|
1390
|
+
];
|
|
1391
|
+
assert_ne!(inodes[0], inodes[1]);
|
|
1392
|
+
assert_ne!(inodes[0], inodes[2]);
|
|
1393
|
+
assert_ne!(inodes[1], inodes[2]);
|
|
1394
|
+
}
|
|
1395
|
+
assert_ne!(variant1_paths.wrapper, variant2_paths.wrapper);
|
|
1396
|
+
assert!(variant1_paths.wrapper.is_file());
|
|
1397
|
+
assert!(variant2_paths.wrapper.is_file());
|
|
1398
|
+
}
|