@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.
Files changed (47) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -156
  4. package/crates/team-agent/src/cli/diagnose.rs +32 -10
  5. package/crates/team-agent/src/cli/emit.rs +6 -185
  6. package/crates/team-agent/src/cli/mod.rs +32 -182
  7. package/crates/team-agent/src/cli/send/coordinator.rs +2 -2
  8. package/crates/team-agent/src/cli/spec.rs +41 -67
  9. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -185
  10. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +9 -9
  11. package/crates/team-agent/src/cli/triage.rs +1 -1
  12. package/crates/team-agent/src/cli/types.rs +0 -49
  13. package/crates/team-agent/src/compiler.rs +3 -0
  14. package/crates/team-agent/src/leader/lease.rs +1 -1
  15. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +211 -16
  16. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +15 -5
  18. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +89 -6
  19. package/crates/team-agent/src/lifecycle/launch/role_source.rs +168 -32
  20. package/crates/team-agent/src/lifecycle/launch.rs +11 -3
  21. package/crates/team-agent/src/lifecycle/profile_launch.rs +23 -8
  22. package/crates/team-agent/src/lifecycle/restart/agent.rs +2 -2
  23. package/crates/team-agent/src/lifecycle/restart/avatar.rs +1806 -0
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +114 -1
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +14 -8
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +14 -2
  27. package/crates/team-agent/src/lifecycle/restart.rs +11 -4
  28. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +2 -2
  29. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +1 -1
  30. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +9 -2
  31. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1398 -0
  32. package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +0 -60
  33. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +2 -2
  34. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  35. package/crates/team-agent/src/lifecycle/types.rs +10 -2
  36. package/crates/team-agent/src/messaging/send.rs +2 -2
  37. package/crates/team-agent/src/model/spec.rs +1 -0
  38. package/crates/team-agent/src/provider/types.rs +3 -1
  39. package/crates/team-agent/src/state/persist.rs +58 -2
  40. package/crates/team-agent/src/state/projection.rs +5 -1
  41. package/crates/team-agent/src/state/repository.rs +125 -9
  42. package/crates/team-agent/src/transport/test_support.rs +23 -3
  43. package/package.json +4 -4
  44. package/skills/team-agent/SKILL.md +17 -46
  45. package/skills/team-agent/references/recovery-runbook.md +2 -2
  46. package/skills/team-agent/references/team-agent-operator.md +4 -2
  47. 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
+ }