@team-agent/installer 0.5.100 → 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 (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +29 -7
  4. package/crates/team-agent/src/cli/mod.rs +24 -8
  5. package/crates/team-agent/src/compiler.rs +3 -0
  6. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +211 -16
  7. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +1 -0
  8. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +15 -5
  9. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +89 -6
  10. package/crates/team-agent/src/lifecycle/launch/role_source.rs +168 -32
  11. package/crates/team-agent/src/lifecycle/launch.rs +11 -3
  12. package/crates/team-agent/src/lifecycle/profile_launch.rs +23 -8
  13. package/crates/team-agent/src/lifecycle/restart/agent.rs +2 -2
  14. package/crates/team-agent/src/lifecycle/restart/avatar.rs +1806 -0
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +114 -1
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +8 -2
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +14 -2
  18. package/crates/team-agent/src/lifecycle/restart.rs +11 -4
  19. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1398 -0
  20. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  21. package/crates/team-agent/src/lifecycle/types.rs +10 -2
  22. package/crates/team-agent/src/model/spec.rs +1 -0
  23. package/crates/team-agent/src/provider/types.rs +2 -0
  24. package/crates/team-agent/src/state/persist.rs +58 -2
  25. package/crates/team-agent/src/state/projection.rs +5 -1
  26. package/crates/team-agent/src/state/repository.rs +125 -4
  27. package/crates/team-agent/src/transport/test_support.rs +23 -3
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +17 -46
  30. package/skills/team-agent/references/team-agent-operator.md +4 -2
@@ -0,0 +1,1806 @@
1
+ //! Pi 的锁内新席位 fork:稳定读取 source JSONL,写入独立 session backing 后精确 resume。
2
+ use std::collections::{BTreeSet, HashSet};
3
+ use std::fs::{self, OpenOptions};
4
+ use std::io::{BufRead, Read, Seek, SeekFrom, Write};
5
+ use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
6
+ use std::path::{Component, Path, PathBuf};
7
+
8
+ use serde_json::Value as JsonValue;
9
+ use sha2::{Digest, Sha256};
10
+
11
+ use crate::lifecycle::launch::add_agent_state::{
12
+ fork_upsert_agent_state_from_role, inject_agent_into_spec,
13
+ };
14
+ use crate::lifecycle::launch::role_source::{
15
+ materialize_latest_role, resolve_role_source, MaterializedRole,
16
+ };
17
+ use crate::lifecycle::launch::ForkCaptureSeed;
18
+ use crate::lifecycle::{
19
+ AgentActionEnvelope, ForkAgentReport, ForkBackingState, LifecycleError, PiForkDetails,
20
+ StartMode,
21
+ };
22
+ use crate::model::ids::AgentId;
23
+ use crate::model::yaml::{self, Value as YamlValue};
24
+ use crate::provider::{CaptureVia, CapturedSession, Confidence, Provider, RolloutPath, SessionId};
25
+ use crate::state::selector::SelectedTeam;
26
+ use crate::transport::{BackendKind, SessionName, Transport, WindowName};
27
+
28
+ use super::common::state_session_name;
29
+
30
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
31
+ struct FileIdentity {
32
+ device: u64,
33
+ inode: u64,
34
+ }
35
+
36
+ #[derive(Clone)]
37
+ struct SourceBinding {
38
+ session_id: SessionId,
39
+ backing_path: PathBuf,
40
+ sessions_root: PathBuf,
41
+ spawn_cwd: PathBuf,
42
+ captured_at: String,
43
+ captured_via: CaptureVia,
44
+ spawned_at: String,
45
+ spawn_epoch: Option<u64>,
46
+ identity: FileIdentity,
47
+ }
48
+
49
+ struct SourceSnapshot {
50
+ body: Vec<u8>,
51
+ }
52
+
53
+ struct StagedPiSession {
54
+ seat_paths: crate::lifecycle::launch::pi_mcp::PiSeatPaths,
55
+ backing_path: PathBuf,
56
+ bytes: Vec<u8>,
57
+ identity: FileIdentity,
58
+ created_dirs: Vec<PathBuf>,
59
+ wrapper_identity: Option<FileIdentity>,
60
+ session_id: SessionId,
61
+ captured_at: String,
62
+ sha256: String,
63
+ }
64
+
65
+ struct SpecRegistration {
66
+ path: PathBuf,
67
+ previous_bytes: Option<Vec<u8>>,
68
+ written_bytes: Vec<u8>,
69
+ target_agent: YamlValue,
70
+ identity: Option<FileIdentity>,
71
+ }
72
+
73
+ /// Called only while `fork_agent_with_transport` holds the selected team's lifecycle lock.
74
+ pub(crate) fn fork_pi_new_seat_locked(
75
+ selected: &SelectedTeam,
76
+ source_agent_id: &AgentId,
77
+ target_agent_id: &AgentId,
78
+ label: Option<&str>,
79
+ transport: &dyn Transport,
80
+ ) -> Result<ForkAgentReport, LifecycleError> {
81
+ let run_workspace = &selected.run_workspace;
82
+ let team_key = selected.team_key.as_str();
83
+ let mut phase = "p0_preflight";
84
+ let mut staged = None;
85
+ let mut role: Option<MaterializedRole> = None;
86
+ let mut spec_registration: Option<SpecRegistration> = None;
87
+ let mut state_registered = false;
88
+ let mut spawn_attempted = false;
89
+ let mut spawned = None;
90
+ let mut panes_before = BTreeSet::new();
91
+ let mut audit = serde_json::json!({
92
+ "team": team_key,
93
+ "source_agent_id": source_agent_id.as_str(),
94
+ "target_agent_id": target_agent_id.as_str(),
95
+ "source_session_id": null,
96
+ "target_session_id": null,
97
+ "source_backing_path": null,
98
+ "backing_path": null,
99
+ "snapshot_bytes": null,
100
+ "snapshot_sha256": null,
101
+ "target_pane_id": null,
102
+ "tmux_endpoint": transport.tmux_endpoint(),
103
+ });
104
+
105
+ let operation = (|| -> Result<ForkAgentReport, LifecycleError> {
106
+ validate_path_component(source_agent_id.as_str(), "source agent id")?;
107
+ validate_path_component(target_agent_id.as_str(), "target agent id")?;
108
+ validate_path_component(team_key, "team id")?;
109
+ if source_agent_id == target_agent_id {
110
+ return Err(LifecycleError::RequirementUnmet(
111
+ "Pi fork target must differ from source".to_string(),
112
+ ));
113
+ }
114
+ if transport.kind() != BackendKind::Tmux {
115
+ return Err(LifecycleError::TeamSelect(
116
+ "Pi fork requires the selected team's tmux transport".to_string(),
117
+ ));
118
+ }
119
+ let expected_transport = super::common::lifecycle_worker_tmux_backend_selection_for_state(
120
+ run_workspace,
121
+ &selected.state,
122
+ )?
123
+ .backend;
124
+ if transport.probes_real_tmux_socket_roots()
125
+ && transport.tmux_endpoint() != expected_transport.tmux_endpoint()
126
+ {
127
+ return Err(LifecycleError::TeamSelect(
128
+ "selected team tmux endpoint changed while fork was acquiring its lifecycle lock"
129
+ .to_string(),
130
+ ));
131
+ }
132
+ crate::lifecycle::launch::ensure_owner_allowed_for_state(
133
+ &selected.state,
134
+ Some(source_agent_id),
135
+ )?;
136
+ if selected
137
+ .state
138
+ .get("agents")
139
+ .and_then(|agents| agents.get(target_agent_id.as_str()))
140
+ .is_some()
141
+ {
142
+ return Err(LifecycleError::RequirementUnmet(format!(
143
+ "target agent already exists in selected team: {target_agent_id}"
144
+ )));
145
+ }
146
+ let session_name = state_session_name(&selected.state);
147
+ if !state_has_session_name(&selected.state) {
148
+ return Err(LifecycleError::TeamSelect(
149
+ "selected team has no persisted tmux session name".to_string(),
150
+ ));
151
+ }
152
+ let source_agent = selected
153
+ .state
154
+ .get("agents")
155
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
156
+ .ok_or_else(|| {
157
+ LifecycleError::RequirementUnmet(format!(
158
+ "unknown worker agent id: {source_agent_id}"
159
+ ))
160
+ })?;
161
+ if source_agent.get("provider").and_then(JsonValue::as_str) != Some("pi")
162
+ || source_agent.get("auth_mode").and_then(JsonValue::as_str) != Some("subscription")
163
+ {
164
+ return Err(LifecycleError::Provider(
165
+ "Pi session fork requires a Pi subscription source seat".to_string(),
166
+ ));
167
+ }
168
+ let binding = source_binding(selected, source_agent_id)?;
169
+ audit["source_session_id"] = serde_json::json!(binding.session_id.as_str());
170
+ audit["source_backing_path"] =
171
+ serde_json::json!(binding.backing_path.to_string_lossy().to_string());
172
+ audit["source_captured_at"] = serde_json::json!(binding.captured_at.as_str());
173
+ audit["source_captured_via"] = serde_json::json!(binding.captured_via);
174
+ audit["source_attribution_confidence"] = serde_json::json!("high");
175
+ audit["source_spawned_at"] = serde_json::json!(binding.spawned_at.as_str());
176
+ audit["source_spawn_epoch"] = serde_json::json!(binding.spawn_epoch);
177
+ let source_role_path = resolve_role_source(
178
+ run_workspace,
179
+ &selected.team_dir,
180
+ &selected.state,
181
+ source_agent_id,
182
+ )?;
183
+ let (source_role_meta, _) = crate::compiler::read_front_matter(&source_role_path)
184
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
185
+ for field in ["working_directory", "cwd"] {
186
+ if let Some(cwd) = source_role_meta.get(field).and_then(YamlValue::as_str) {
187
+ if Path::new(cwd).canonicalize().ok().as_deref()
188
+ != Some(binding.spawn_cwd.as_path())
189
+ {
190
+ return Err(LifecycleError::Compile(format!(
191
+ "Pi source role {field} differs from the captured source cwd"
192
+ )));
193
+ }
194
+ }
195
+ }
196
+ let team_meta = crate::compiler::read_front_matter(&selected.team_dir.join("TEAM.md"))
197
+ .map(|(meta, _)| meta)
198
+ .unwrap_or(YamlValue::Null);
199
+ let workspace_text = run_workspace.to_string_lossy().to_string();
200
+ let source_compiled =
201
+ crate::compiler::compile_role_agent(&source_role_path, &team_meta, &workspace_text)
202
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
203
+ if source_compiled.id != source_agent_id.as_str() {
204
+ return Err(LifecycleError::Compile(
205
+ "latest source role id does not match the source seat".to_string(),
206
+ ));
207
+ }
208
+ if source_compiled
209
+ .agent
210
+ .get("provider")
211
+ .and_then(YamlValue::as_str)
212
+ != Some("pi")
213
+ || source_compiled
214
+ .agent
215
+ .get("auth_mode")
216
+ .and_then(YamlValue::as_str)
217
+ != Some("subscription")
218
+ {
219
+ return Err(LifecycleError::Compile(
220
+ "latest source role must resolve to Pi subscription".to_string(),
221
+ ));
222
+ }
223
+
224
+ let mut base_spec = crate::compiler::compile_team(&selected.team_dir)
225
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
226
+ crate::lifecycle::launch::spec_state::override_spec_workspace(
227
+ &mut base_spec,
228
+ run_workspace,
229
+ );
230
+ let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
231
+ ensure_no_symlink_components(&spec_path, true)?;
232
+ let previous_spec_bytes = read_optional_bytes(&spec_path)?;
233
+ let runtime_spec_has_target = match previous_spec_bytes.as_deref() {
234
+ Some(bytes) => {
235
+ let text = std::str::from_utf8(bytes)
236
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
237
+ let spec = yaml::loads(text)
238
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
239
+ spec_has_target(&spec, target_agent_id.as_str())
240
+ }
241
+ None => false,
242
+ };
243
+ if spec_has_target(&base_spec, target_agent_id.as_str()) || runtime_spec_has_target {
244
+ return Err(LifecycleError::RequirementUnmet(format!(
245
+ "target agent or route already exists in selected team spec: {target_agent_id}"
246
+ )));
247
+ }
248
+ let managed_role_path = run_workspace
249
+ .join(".team")
250
+ .join("dynamic-role-files")
251
+ .join(format!("{}.md", target_agent_id.as_str()));
252
+ ensure_no_symlink_components(&managed_role_path, true)?;
253
+ refuse_existing_path(&managed_role_path, "target managed role file")?;
254
+ let target_paths = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
255
+ run_workspace,
256
+ team_key,
257
+ target_agent_id.as_str(),
258
+ );
259
+ ensure_no_symlink_components(&target_paths.runtime_root, true)?;
260
+ refuse_existing_path(&target_paths.runtime_root, "target Pi runtime seat")?;
261
+ let targets = transport
262
+ .list_targets()
263
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
264
+ if targets.iter().any(|pane| {
265
+ pane.session.as_str() == session_name.as_str()
266
+ && pane.window_name.as_ref().map(WindowName::as_str)
267
+ == Some(target_agent_id.as_str())
268
+ }) {
269
+ return Err(LifecycleError::RequirementUnmet(format!(
270
+ "target window already exists: {}:{}",
271
+ session_name.as_str(),
272
+ target_agent_id.as_str()
273
+ )));
274
+ }
275
+ panes_before.extend(
276
+ targets
277
+ .into_iter()
278
+ .map(|pane| pane.pane_id.as_str().to_string()),
279
+ );
280
+
281
+ phase = "p1_snapshot";
282
+ let snapshot = read_stable_source_snapshot(&binding, run_workspace)?;
283
+ revalidate_source_binding(selected, source_agent_id, &binding)?;
284
+ let target_session_id = crate::lifecycle::launch::pi_mcp::new_pi_session_id();
285
+ let full_state = crate::state::persist::load_runtime_state(run_workspace)
286
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
287
+ if target_session_id == binding.session_id
288
+ || session_id_in_use(&full_state, &target_session_id)
289
+ {
290
+ return Err(LifecycleError::RequirementUnmet(
291
+ "generated Pi fork session id is not unique in selected team".to_string(),
292
+ ));
293
+ }
294
+ let captured_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
295
+ let staged_file = stage_pi_session(
296
+ run_workspace,
297
+ &target_paths,
298
+ &target_session_id,
299
+ &captured_at,
300
+ &binding.spawn_cwd,
301
+ &binding.backing_path,
302
+ &snapshot.body,
303
+ )?;
304
+ audit["target_session_id"] = serde_json::json!(target_session_id.as_str());
305
+ audit["backing_path"] =
306
+ serde_json::json!(staged_file.backing_path.to_string_lossy().to_string());
307
+ audit["snapshot_bytes"] = serde_json::json!(staged_file.bytes.len());
308
+ audit["snapshot_sha256"] = serde_json::json!(staged_file.sha256.as_str());
309
+ staged = Some(staged_file);
310
+
311
+ let target_role = materialize_latest_role(
312
+ run_workspace,
313
+ &selected.team_dir,
314
+ &selected.state,
315
+ source_agent_id,
316
+ target_agent_id,
317
+ label,
318
+ Some(target_agent_id.as_str()),
319
+ )?;
320
+ role = Some(target_role);
321
+ let target_role_path = role
322
+ .as_ref()
323
+ .ok_or_else(|| {
324
+ LifecycleError::StatePersist("materialized role disappeared".to_string())
325
+ })?
326
+ .path()
327
+ .to_path_buf();
328
+ let (target_meta, _) = crate::compiler::read_front_matter(&target_role_path)
329
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
330
+ for field in ["working_directory", "cwd"] {
331
+ if let Some(cwd) = target_meta.get(field).and_then(YamlValue::as_str) {
332
+ if Path::new(cwd).canonicalize().ok().as_deref()
333
+ != Some(binding.spawn_cwd.as_path())
334
+ {
335
+ return Err(LifecycleError::Compile(format!(
336
+ "Pi fork target role {field} differs from the source cohort cwd"
337
+ )));
338
+ }
339
+ }
340
+ }
341
+ let compiled =
342
+ crate::compiler::compile_role_agent(&target_role_path, &team_meta, &workspace_text)
343
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
344
+ if compiled.id != target_agent_id.as_str() {
345
+ return Err(LifecycleError::Compile(format!(
346
+ "materialized role id '{}' does not match target '{}'",
347
+ compiled.id, target_agent_id
348
+ )));
349
+ }
350
+ let compiled_provider = compiled.agent.get("provider").and_then(YamlValue::as_str);
351
+ let compiled_auth = compiled.agent.get("auth_mode").and_then(YamlValue::as_str);
352
+ if compiled_provider != Some("pi") || compiled_auth != Some("subscription") {
353
+ return Err(LifecycleError::Compile(
354
+ "compiled target role must remain Pi subscription".to_string(),
355
+ ));
356
+ }
357
+ let cwd = compiled
358
+ .agent
359
+ .get("working_directory")
360
+ .and_then(YamlValue::as_str)
361
+ .ok_or_else(|| {
362
+ LifecycleError::Compile("Pi target role has no working_directory".to_string())
363
+ })?;
364
+ if Path::new(cwd).canonicalize().ok().as_deref() != Some(binding.spawn_cwd.as_path()) {
365
+ return Err(LifecycleError::Compile(
366
+ "Pi fork target role working_directory differs from the source cohort cwd"
367
+ .to_string(),
368
+ ));
369
+ }
370
+ let staged_file = staged.as_ref().ok_or_else(|| {
371
+ LifecycleError::StatePersist("Pi fork snapshot staging was lost".to_string())
372
+ })?;
373
+ let seed = ForkCaptureSeed {
374
+ source_agent_id: source_agent_id.clone(),
375
+ captured: CapturedSession {
376
+ session_id: Some(staged_file.session_id.clone()),
377
+ rollout_path: Some(RolloutPath::new(staged_file.backing_path.clone())),
378
+ captured_via: CaptureVia::ForkSnapshot,
379
+ attribution_confidence: Confidence::High,
380
+ spawn_cwd: binding.spawn_cwd.clone(),
381
+ },
382
+ captured_at: staged_file.captured_at.clone(),
383
+ pi_sessions_root: target_paths.sessions.clone(),
384
+ profile_dir: selected.team_dir.join("profiles"),
385
+ };
386
+
387
+ phase = "p2_register";
388
+ revalidate_source_binding(selected, source_agent_id, &binding)?;
389
+ ensure_no_symlink_components(&spec_path, true)?;
390
+ let current_spec_bytes = read_optional_bytes(&spec_path)?;
391
+ if previous_spec_bytes.is_some() && current_spec_bytes.is_none() {
392
+ return Err(LifecycleError::RequirementUnmet(
393
+ "runtime spec disappeared during Pi fork preparation".to_string(),
394
+ ));
395
+ }
396
+ let mut latest_spec = match current_spec_bytes.as_deref() {
397
+ Some(bytes) => yaml::loads(std::str::from_utf8(bytes).map_err(|error| {
398
+ LifecycleError::Compile(format!("runtime spec is not UTF-8: {error}"))
399
+ })?)
400
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?,
401
+ None => base_spec,
402
+ };
403
+ if spec_has_target(&latest_spec, target_agent_id.as_str()) {
404
+ return Err(LifecycleError::RequirementUnmet(format!(
405
+ "target agent or route appeared in runtime spec during fork preparation: {target_agent_id}"
406
+ )));
407
+ }
408
+ inject_agent_into_spec(
409
+ &mut latest_spec,
410
+ compiled.agent.clone(),
411
+ target_agent_id.as_str(),
412
+ )?;
413
+ let written_bytes = yaml::dumps(&latest_spec).into_bytes();
414
+ if read_optional_bytes(&spec_path)? != current_spec_bytes {
415
+ return Err(LifecycleError::RequirementUnmet(
416
+ "runtime spec changed while registering Pi fork target".to_string(),
417
+ ));
418
+ }
419
+ spec_registration = Some(SpecRegistration {
420
+ path: spec_path.clone(),
421
+ previous_bytes: current_spec_bytes,
422
+ written_bytes,
423
+ target_agent: compiled.agent.clone(),
424
+ identity: None,
425
+ });
426
+ crate::lifecycle::launch::spec_state::write_spec_atomic(&spec_path, &latest_spec)?;
427
+ let spec_identity = file_identity(&spec_path).map_err(LifecycleError::StatePersist)?;
428
+ if let Some(registration) = spec_registration.as_mut() {
429
+ registration.identity = Some(spec_identity);
430
+ }
431
+ match fork_upsert_agent_state_from_role(
432
+ run_workspace,
433
+ team_key,
434
+ target_agent_id,
435
+ &target_meta,
436
+ &compiled.agent,
437
+ &target_role_path,
438
+ &seed,
439
+ ) {
440
+ Ok(()) => state_registered = true,
441
+ Err(error) => {
442
+ state_registered = staged.as_ref().is_some_and(|staged| {
443
+ fork_target_state_registered(
444
+ run_workspace,
445
+ team_key,
446
+ target_agent_id,
447
+ source_agent_id,
448
+ &staged.session_id,
449
+ &staged.backing_path,
450
+ &target_role_path,
451
+ )
452
+ });
453
+ return Err(error);
454
+ }
455
+ }
456
+
457
+ phase = "p3_spawn";
458
+ spawn_attempted = true;
459
+ let latest_state =
460
+ crate::state::projection::select_runtime_state(run_workspace, Some(team_key))
461
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?;
462
+ let target_row = latest_state
463
+ .get("agents")
464
+ .and_then(|agents| agents.get(target_agent_id.as_str()))
465
+ .ok_or_else(|| {
466
+ LifecycleError::StatePersist(
467
+ "registered Pi fork target row disappeared".to_string(),
468
+ )
469
+ })?;
470
+ let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn_json(
471
+ target_row,
472
+ Provider::Pi,
473
+ )?;
474
+ let spawn = super::common::spawn_agent_window(
475
+ run_workspace,
476
+ &session_name,
477
+ target_agent_id,
478
+ target_row,
479
+ Some(
480
+ &staged
481
+ .as_ref()
482
+ .ok_or_else(|| {
483
+ LifecycleError::StatePersist(
484
+ "Pi fork snapshot staging was lost".to_string(),
485
+ )
486
+ })?
487
+ .session_id,
488
+ ),
489
+ true,
490
+ transport,
491
+ Some(&safety),
492
+ None,
493
+ Some(run_workspace),
494
+ Some("fork-agent"),
495
+ Some(team_key),
496
+ );
497
+ let spawn = match spawn {
498
+ Ok(spawn) => spawn,
499
+ Err(error) => {
500
+ capture_target_wrapper_identity(staged.as_mut());
501
+ return Err(error);
502
+ }
503
+ };
504
+ capture_target_wrapper_identity(staged.as_mut());
505
+ audit["target_pane_id"] = serde_json::json!(spawn.spawn.pane_id.as_str());
506
+ spawned = Some(spawn);
507
+ let spawned_ref = spawned.as_ref().ok_or_else(|| {
508
+ LifecycleError::StatePersist("Pi fork spawn result disappeared".to_string())
509
+ })?;
510
+ super::agent::verify_spawned_pane_matches_target(
511
+ transport,
512
+ &spawned_ref.spawn.pane_id,
513
+ &session_name,
514
+ &WindowName::new(target_agent_id.as_str()),
515
+ )?;
516
+
517
+ phase = "p4_persist";
518
+ let mut updated_state =
519
+ crate::state::projection::select_runtime_state(run_workspace, Some(team_key))
520
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?;
521
+ super::agent::mark_agent_started(
522
+ &mut updated_state,
523
+ target_agent_id,
524
+ target_agent_id.as_str(),
525
+ spawned_ref,
526
+ transport,
527
+ &safety,
528
+ StartMode::Resumed,
529
+ )?;
530
+ let updated_target = updated_state
531
+ .get("agents")
532
+ .and_then(|agents| agents.get(target_agent_id.as_str()))
533
+ .cloned()
534
+ .ok_or_else(|| {
535
+ LifecycleError::StatePersist("started Pi fork target row disappeared".to_string())
536
+ })?;
537
+ crate::state::repository::StateRepository::new(run_workspace)
538
+ .commit_fork_agent(
539
+ crate::state::repository::StateWriteIntent::ForkAgent {
540
+ team_key,
541
+ agent_id: target_agent_id.as_str(),
542
+ },
543
+ &updated_target,
544
+ )
545
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
546
+ let _ = crate::db::agent_health_capture::clear_agent_health_observation(
547
+ run_workspace,
548
+ team_key,
549
+ target_agent_id,
550
+ );
551
+ let coordinator =
552
+ super::common::start_coordinator_for_workspace(run_workspace, Some(team_key))?;
553
+ if !coordinator.ok {
554
+ return Err(LifecycleError::StatePersist(format!(
555
+ "coordinator did not become available after Pi fork: {}",
556
+ coordinator.status
557
+ )));
558
+ }
559
+
560
+ phase = "p5_receipt";
561
+ let staged_file = staged.as_ref().ok_or_else(|| {
562
+ LifecycleError::StatePersist("Pi fork snapshot staging was lost".to_string())
563
+ })?;
564
+ Ok(ForkAgentReport {
565
+ source_agent_id: source_agent_id.clone(),
566
+ new_agent_id: target_agent_id.clone(),
567
+ env: AgentActionEnvelope {
568
+ agent_id: target_agent_id.clone(),
569
+ state_file: crate::state::persist::runtime_state_path(run_workspace),
570
+ coordinator_started: coordinator.ok,
571
+ },
572
+ session_id: Some(staged_file.session_id.clone()),
573
+ backing_state: ForkBackingState::Verified,
574
+ pi_fork: Some(PiForkDetails {
575
+ source_session_id: binding.session_id.clone(),
576
+ session_id: staged_file.session_id.clone(),
577
+ backing_path: RolloutPath::new(staged_file.backing_path.clone()),
578
+ }),
579
+ })
580
+ })();
581
+
582
+ match operation {
583
+ Ok(report) => {
584
+ if let Err(error) = write_fork_audit(
585
+ run_workspace,
586
+ &mut audit,
587
+ "succeeded",
588
+ "complete",
589
+ "not_required",
590
+ ) {
591
+ let compensation = compensate_fork(
592
+ run_workspace,
593
+ team_key,
594
+ target_agent_id,
595
+ source_agent_id,
596
+ transport,
597
+ &session_name_for_audit(&selected.state),
598
+ &panes_before,
599
+ spawn_attempted,
600
+ spawned.as_ref(),
601
+ spec_registration.as_ref(),
602
+ state_registered,
603
+ staged.as_ref(),
604
+ role.as_mut(),
605
+ );
606
+ return Err(LifecycleError::StatePersist(if compensation.is_empty() {
607
+ format!("fork succeeded but audit receipt failed: {error}")
608
+ } else {
609
+ format!("fork succeeded but audit receipt failed: {error}; compensation incomplete: {compensation}")
610
+ }));
611
+ }
612
+ if let Some(role) = role.as_mut() {
613
+ role.keep();
614
+ }
615
+ Ok(report)
616
+ }
617
+ Err(error) => {
618
+ let compensation = compensate_fork(
619
+ run_workspace,
620
+ team_key,
621
+ target_agent_id,
622
+ source_agent_id,
623
+ transport,
624
+ &session_name_for_audit(&selected.state),
625
+ &panes_before,
626
+ spawn_attempted,
627
+ spawned.as_ref(),
628
+ spec_registration.as_ref(),
629
+ state_registered,
630
+ staged.as_ref(),
631
+ role.as_mut(),
632
+ );
633
+ let result = if compensation.is_empty() {
634
+ "complete"
635
+ } else {
636
+ "incomplete"
637
+ };
638
+ audit["failure"] = serde_json::json!(phase);
639
+ let audit_error =
640
+ write_fork_audit(run_workspace, &mut audit, "failed", phase, result).err();
641
+ let audit_detail = audit_error
642
+ .map(|audit_error| format!("; failure receipt failed: {audit_error}"))
643
+ .unwrap_or_default();
644
+ if compensation.is_empty() && audit_detail.is_empty() {
645
+ Err(error)
646
+ } else if compensation.is_empty() {
647
+ Err(LifecycleError::StatePersist(format!(
648
+ "{error}{audit_detail}"
649
+ )))
650
+ } else {
651
+ Err(LifecycleError::StatePersist(format!(
652
+ "{error}; compensation incomplete: {compensation}{audit_detail}"
653
+ )))
654
+ }
655
+ }
656
+ }
657
+ }
658
+
659
+ fn source_binding(
660
+ selected: &SelectedTeam,
661
+ source_agent_id: &AgentId,
662
+ ) -> Result<SourceBinding, LifecycleError> {
663
+ let source = selected
664
+ .state
665
+ .get("agents")
666
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
667
+ .ok_or_else(|| LifecycleError::RequirementUnmet("Pi source row disappeared".to_string()))?;
668
+ if source.get("provider").and_then(JsonValue::as_str) != Some("pi")
669
+ || source.get("auth_mode").and_then(JsonValue::as_str) != Some("subscription")
670
+ || source.get("capture_state").and_then(JsonValue::as_str) != Some("captured")
671
+ {
672
+ return Err(LifecycleError::RequirementUnmet(
673
+ "Pi source does not have a complete captured subscription session".to_string(),
674
+ ));
675
+ }
676
+ if source
677
+ .get("owner_team_id")
678
+ .and_then(JsonValue::as_str)
679
+ .is_some_and(|owner| owner != selected.team_key)
680
+ {
681
+ return Err(LifecycleError::TeamSelect(
682
+ "Pi source owner does not match the selected team".to_string(),
683
+ ));
684
+ }
685
+ let session_id = source
686
+ .get("session_id")
687
+ .and_then(JsonValue::as_str)
688
+ .filter(|value| !value.is_empty())
689
+ .map(SessionId::new)
690
+ .ok_or_else(|| {
691
+ LifecycleError::RequirementUnmet("Pi source session_id is missing".to_string())
692
+ })?;
693
+ let attribution_confidence = source
694
+ .get("attribution_confidence")
695
+ .and_then(JsonValue::as_str);
696
+ if attribution_confidence.is_some_and(|confidence| confidence != "high")
697
+ || source
698
+ .get("attribution_ambiguous")
699
+ .and_then(JsonValue::as_bool)
700
+ == Some(true)
701
+ {
702
+ return Err(LifecycleError::RequirementUnmet(
703
+ "Pi source capture attribution is explicitly low-confidence or ambiguous".to_string(),
704
+ ));
705
+ }
706
+ let captured_at = required_state_string(source, "captured_at")?;
707
+ let captured_via: CaptureVia = serde_json::from_value(serde_json::json!(
708
+ required_state_string(source, "captured_via")?
709
+ ))
710
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
711
+ let spawned_at = required_state_string(source, "spawned_at")?;
712
+ chrono::DateTime::parse_from_rfc3339(&captured_at)
713
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
714
+ chrono::DateTime::parse_from_rfc3339(&spawned_at)
715
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
716
+ let spawn_cwd = PathBuf::from(required_state_string(source, "spawn_cwd")?);
717
+ if !spawn_cwd.is_absolute() {
718
+ return Err(LifecycleError::RequirementUnmet(
719
+ "Pi source spawn_cwd is not absolute".to_string(),
720
+ ));
721
+ }
722
+ let canonical_cwd = fs::canonicalize(&spawn_cwd)
723
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
724
+ let canonical_workspace = fs::canonicalize(&selected.run_workspace)
725
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
726
+ if canonical_cwd != canonical_workspace {
727
+ return Err(LifecycleError::RequirementUnmet(
728
+ "Pi source spawn_cwd differs from the selected team workspace".to_string(),
729
+ ));
730
+ }
731
+ for field in ["cwd", "working_directory"] {
732
+ if let Some(cwd) = source.get(field).and_then(JsonValue::as_str) {
733
+ if Path::new(cwd).canonicalize().ok().as_deref()
734
+ != Some(selected.run_workspace.as_path())
735
+ {
736
+ return Err(LifecycleError::RequirementUnmet(format!(
737
+ "Pi source {field} differs from the selected target working directory"
738
+ )));
739
+ }
740
+ }
741
+ }
742
+ let backing_path = PathBuf::from(required_state_string(source, "rollout_path")?);
743
+ if backing_path
744
+ .extension()
745
+ .and_then(|extension| extension.to_str())
746
+ != Some("jsonl")
747
+ {
748
+ return Err(LifecycleError::RequirementUnmet(
749
+ "Pi source backing is not a .jsonl session file".to_string(),
750
+ ));
751
+ }
752
+ if !backing_path.is_absolute() {
753
+ return Err(LifecycleError::RequirementUnmet(
754
+ "Pi source rollout_path is not absolute".to_string(),
755
+ ));
756
+ }
757
+ ensure_no_symlink_components(&backing_path, false)?;
758
+ let canonical_backing = fs::canonicalize(&backing_path)
759
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
760
+ if canonical_backing != backing_path {
761
+ return Err(LifecycleError::RequirementUnmet(
762
+ "Pi source rollout_path is not its canonical exact path".to_string(),
763
+ ));
764
+ }
765
+ let expected_root = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
766
+ &selected.run_workspace,
767
+ &selected.team_key,
768
+ source_agent_id.as_str(),
769
+ )
770
+ .sessions;
771
+ ensure_no_symlink_components(&expected_root, false)?;
772
+ let canonical_root = fs::canonicalize(&expected_root)
773
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
774
+ if source
775
+ .get("claude_projects_root")
776
+ .and_then(JsonValue::as_str)
777
+ .map(PathBuf::from)
778
+ .and_then(|path| fs::canonicalize(path).ok())
779
+ .as_ref()
780
+ != Some(&canonical_root)
781
+ {
782
+ return Err(LifecycleError::RequirementUnmet(
783
+ "Pi source recorded session root does not match its managed seat".to_string(),
784
+ ));
785
+ }
786
+ if !canonical_backing.starts_with(&canonical_root) {
787
+ return Err(LifecycleError::RequirementUnmet(
788
+ "Pi source backing is outside its recorded session root".to_string(),
789
+ ));
790
+ }
791
+ let file = fs::File::open(&backing_path)
792
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
793
+ let mut reader = std::io::BufReader::new(file).take(64 * 1024);
794
+ let mut header_line = String::new();
795
+ let header_bytes = reader
796
+ .read_line(&mut header_line)
797
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
798
+ if header_bytes == 0 || !header_line.ends_with('\n') {
799
+ return Err(LifecycleError::RequirementUnmet(
800
+ "Pi source session header is incomplete".to_string(),
801
+ ));
802
+ }
803
+ let header: JsonValue = serde_json::from_str(header_line.trim_end())
804
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
805
+ let header_cwd = header
806
+ .get("cwd")
807
+ .and_then(JsonValue::as_str)
808
+ .map(PathBuf::from)
809
+ .ok_or_else(|| {
810
+ LifecycleError::RequirementUnmet("Pi source header cwd is missing".into())
811
+ })?;
812
+ let canonical_header_cwd = fs::canonicalize(&header_cwd)
813
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
814
+ if canonical_header_cwd != canonical_cwd || canonical_header_cwd != canonical_workspace {
815
+ return Err(LifecycleError::RequirementUnmet(
816
+ "Pi source header cwd differs from its capture tuple or target workspace".into(),
817
+ ));
818
+ }
819
+ crate::provider::session_scan::pi::validate_exact_backing(
820
+ &backing_path,
821
+ &session_id,
822
+ &canonical_cwd,
823
+ )
824
+ .map_err(|error| LifecycleError::Provider(error.to_string()))?;
825
+ let identity = file_identity(&backing_path).map_err(LifecycleError::RequirementUnmet)?;
826
+ Ok(SourceBinding {
827
+ session_id,
828
+ backing_path,
829
+ sessions_root: canonical_root,
830
+ spawn_cwd: canonical_cwd,
831
+ captured_at: captured_at.to_string(),
832
+ captured_via,
833
+ spawned_at: spawned_at.to_string(),
834
+ spawn_epoch: source.get("spawn_epoch").and_then(JsonValue::as_u64),
835
+ identity,
836
+ })
837
+ }
838
+
839
+ fn revalidate_source_binding(
840
+ selected: &SelectedTeam,
841
+ source_agent_id: &AgentId,
842
+ expected: &SourceBinding,
843
+ ) -> Result<(), LifecycleError> {
844
+ let current = crate::state::projection::select_runtime_state(
845
+ &selected.run_workspace,
846
+ Some(&selected.team_key),
847
+ )
848
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?;
849
+ let latest = source_binding_from_state(&current, selected, source_agent_id)?;
850
+ if latest.session_id != expected.session_id
851
+ || latest.backing_path != expected.backing_path
852
+ || latest.sessions_root != expected.sessions_root
853
+ || latest.spawn_cwd != expected.spawn_cwd
854
+ || latest.captured_at != expected.captured_at
855
+ || latest.captured_via != expected.captured_via
856
+ || latest.spawned_at != expected.spawned_at
857
+ || latest.spawn_epoch != expected.spawn_epoch
858
+ || latest.identity != expected.identity
859
+ {
860
+ return Err(LifecycleError::RequirementUnmet(
861
+ "Pi source session cohort changed during fork preparation".to_string(),
862
+ ));
863
+ }
864
+ Ok(())
865
+ }
866
+
867
+ fn source_binding_from_state(
868
+ state: &JsonValue,
869
+ selected: &SelectedTeam,
870
+ source_agent_id: &AgentId,
871
+ ) -> Result<SourceBinding, LifecycleError> {
872
+ let mut snapshot = selected.clone();
873
+ snapshot.state = state.clone();
874
+ source_binding(&snapshot, source_agent_id)
875
+ }
876
+
877
+ fn read_stable_source_snapshot(
878
+ binding: &SourceBinding,
879
+ run_workspace: &Path,
880
+ ) -> Result<SourceSnapshot, LifecycleError> {
881
+ ensure_no_symlink_components(&binding.backing_path, false)?;
882
+ let before_path = fs::symlink_metadata(&binding.backing_path)
883
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
884
+ if !before_path.file_type().is_file()
885
+ || identity_from_metadata(&before_path) != binding.identity
886
+ {
887
+ return Err(LifecycleError::RequirementUnmet(
888
+ "Pi source backing is not the captured regular file".to_string(),
889
+ ));
890
+ }
891
+ let mut file = OpenOptions::new()
892
+ .read(true)
893
+ .custom_flags(libc::O_NOFOLLOW)
894
+ .open(&binding.backing_path)
895
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
896
+ let open_before = file
897
+ .metadata()
898
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
899
+ if identity_from_metadata(&open_before) != binding.identity {
900
+ return Err(LifecycleError::RequirementUnmet(
901
+ "Pi source backing changed while opening".to_string(),
902
+ ));
903
+ }
904
+ let mut first = Vec::new();
905
+ file.read_to_end(&mut first)
906
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
907
+ let middle = file
908
+ .metadata()
909
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
910
+ file.seek(SeekFrom::Start(0))
911
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
912
+ let mut second = Vec::new();
913
+ file.read_to_end(&mut second)
914
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
915
+ let after = file
916
+ .metadata()
917
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
918
+ let after_path = fs::symlink_metadata(&binding.backing_path)
919
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
920
+ if first != second
921
+ || identity_from_metadata(&middle) != binding.identity
922
+ || identity_from_metadata(&after) != binding.identity
923
+ || identity_from_metadata(&after_path) != binding.identity
924
+ || middle.len() != after.len()
925
+ {
926
+ return Err(LifecycleError::RequirementUnmet(
927
+ "Pi source JSONL changed during stable snapshot read".to_string(),
928
+ ));
929
+ }
930
+ let canonical_workspace = fs::canonicalize(run_workspace)
931
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
932
+ if canonical_workspace != binding.spawn_cwd {
933
+ return Err(LifecycleError::RequirementUnmet(
934
+ "Pi source capture cwd differs from the selected target workspace".into(),
935
+ ));
936
+ }
937
+ let body = validate_session_bytes(&first, &binding.session_id, &canonical_workspace, None)?;
938
+ Ok(SourceSnapshot { body })
939
+ }
940
+
941
+ fn validate_session_bytes(
942
+ bytes: &[u8],
943
+ expected_id: &SessionId,
944
+ expected_cwd: &Path,
945
+ expected_parent: Option<&Path>,
946
+ ) -> Result<Vec<u8>, LifecycleError> {
947
+ let header_end = bytes
948
+ .iter()
949
+ .position(|byte| *byte == b'\n')
950
+ .ok_or_else(|| {
951
+ LifecycleError::RequirementUnmet("Pi session header is incomplete".to_string())
952
+ })?;
953
+ let header_line = trim_line_ending(&bytes[..header_end]);
954
+ let header: JsonValue = serde_json::from_slice(header_line)
955
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
956
+ if header.get("type").and_then(JsonValue::as_str) != Some("session")
957
+ || header.get("version").and_then(JsonValue::as_u64) != Some(3)
958
+ || header.get("id").and_then(JsonValue::as_str) != Some(expected_id.as_str())
959
+ {
960
+ return Err(LifecycleError::RequirementUnmet(
961
+ "Pi session header type/version/id mismatch".to_string(),
962
+ ));
963
+ }
964
+ let cwd = header
965
+ .get("cwd")
966
+ .and_then(JsonValue::as_str)
967
+ .map(PathBuf::from)
968
+ .ok_or_else(|| LifecycleError::RequirementUnmet("Pi session cwd is missing".to_string()))?;
969
+ let canonical_cwd = fs::canonicalize(&cwd)
970
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
971
+ let canonical_expected_cwd = fs::canonicalize(expected_cwd)
972
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
973
+ if !cwd.is_absolute() || canonical_cwd != canonical_expected_cwd {
974
+ return Err(LifecycleError::RequirementUnmet(
975
+ "Pi session header cwd does not match the captured cohort".to_string(),
976
+ ));
977
+ }
978
+ let timestamp = header
979
+ .get("timestamp")
980
+ .and_then(JsonValue::as_str)
981
+ .ok_or_else(|| {
982
+ LifecycleError::RequirementUnmet("Pi session timestamp is missing".to_string())
983
+ })?;
984
+ chrono::DateTime::parse_from_rfc3339(timestamp)
985
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
986
+ if let Some(expected_parent) = expected_parent {
987
+ if header
988
+ .get("parentSession")
989
+ .and_then(JsonValue::as_str)
990
+ .map(PathBuf::from)
991
+ .as_deref()
992
+ != Some(expected_parent)
993
+ {
994
+ return Err(LifecycleError::RequirementUnmet(
995
+ "Pi fork parentSession does not match the exact source backing".to_string(),
996
+ ));
997
+ }
998
+ }
999
+ let body = &bytes[header_end + 1..];
1000
+ if !body.is_empty() && !body.ends_with(b"\n") {
1001
+ return Err(LifecycleError::RequirementUnmet(
1002
+ "Pi session has an incomplete trailing JSONL record".to_string(),
1003
+ ));
1004
+ }
1005
+ let mut ids = HashSet::new();
1006
+ for record in body.split_inclusive(|byte| *byte == b'\n') {
1007
+ if record.is_empty() || !record.ends_with(b"\n") {
1008
+ return Err(LifecycleError::RequirementUnmet(
1009
+ "Pi session contains an incomplete JSONL record".to_string(),
1010
+ ));
1011
+ }
1012
+ let json: JsonValue = serde_json::from_slice(trim_line_ending(record))
1013
+ .map_err(|error| LifecycleError::RequirementUnmet(error.to_string()))?;
1014
+ if json
1015
+ .get("type")
1016
+ .and_then(JsonValue::as_str)
1017
+ .is_none_or(|kind| kind.is_empty() || kind == "session")
1018
+ {
1019
+ return Err(LifecycleError::RequirementUnmet(
1020
+ "Pi session entry type is missing or invalid".to_string(),
1021
+ ));
1022
+ }
1023
+ let id = json
1024
+ .get("id")
1025
+ .and_then(JsonValue::as_str)
1026
+ .filter(|id| !id.is_empty())
1027
+ .ok_or_else(|| {
1028
+ LifecycleError::RequirementUnmet("Pi session entry id is missing".to_string())
1029
+ })?;
1030
+ if ids.contains(id) {
1031
+ return Err(LifecycleError::RequirementUnmet(format!(
1032
+ "Pi session entry id is duplicated: {id}"
1033
+ )));
1034
+ }
1035
+ let parent = json.get("parentId").ok_or_else(|| {
1036
+ LifecycleError::RequirementUnmet("Pi session entry parentId is missing".to_string())
1037
+ })?;
1038
+ match parent {
1039
+ JsonValue::Null => {}
1040
+ JsonValue::String(parent) if ids.contains(parent) => {}
1041
+ JsonValue::String(parent) => {
1042
+ return Err(LifecycleError::RequirementUnmet(format!(
1043
+ "Pi session parentId does not reference an earlier entry: {parent}"
1044
+ )))
1045
+ }
1046
+ _ => {
1047
+ return Err(LifecycleError::RequirementUnmet(
1048
+ "Pi session parentId must be string or null".to_string(),
1049
+ ))
1050
+ }
1051
+ }
1052
+ ids.insert(id.to_string());
1053
+ }
1054
+ Ok(body.to_vec())
1055
+ }
1056
+
1057
+ fn trim_line_ending(line: &[u8]) -> &[u8] {
1058
+ let line = line.strip_suffix(b"\n").unwrap_or(line);
1059
+ line.strip_suffix(b"\r").unwrap_or(line)
1060
+ }
1061
+
1062
+ fn stage_pi_session(
1063
+ workspace: &Path,
1064
+ paths: &crate::lifecycle::launch::pi_mcp::PiSeatPaths,
1065
+ session_id: &SessionId,
1066
+ timestamp: &str,
1067
+ cwd: &Path,
1068
+ parent_session: &Path,
1069
+ body: &[u8],
1070
+ ) -> Result<StagedPiSession, LifecycleError> {
1071
+ let file_timestamp = timestamp.replace(':', "-").replace('.', "-");
1072
+ let backing_path = paths
1073
+ .sessions
1074
+ .join(format!("{file_timestamp}_{}.jsonl", session_id.as_str()));
1075
+ let header = serde_json::json!({
1076
+ "type": "session",
1077
+ "version": 3,
1078
+ "id": session_id.as_str(),
1079
+ "timestamp": timestamp,
1080
+ "cwd": cwd.to_string_lossy(),
1081
+ "parentSession": parent_session.to_string_lossy(),
1082
+ });
1083
+ let mut bytes = serde_json::to_vec(&header)
1084
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
1085
+ bytes.push(b'\n');
1086
+ bytes.extend_from_slice(body);
1087
+ validate_session_bytes(&bytes, session_id, cwd, Some(parent_session))?;
1088
+ let created_dirs = create_target_session_dirs(workspace, paths)?;
1089
+ let mut file = match OpenOptions::new()
1090
+ .write(true)
1091
+ .create_new(true)
1092
+ .mode(0o600)
1093
+ .custom_flags(libc::O_NOFOLLOW)
1094
+ .open(&backing_path)
1095
+ {
1096
+ Ok(file) => file,
1097
+ Err(error) => {
1098
+ cleanup_created_dirs(&created_dirs);
1099
+ return Err(if error.kind() == std::io::ErrorKind::AlreadyExists {
1100
+ LifecycleError::RequirementUnmet(format!(
1101
+ "target Pi backing already exists: {}",
1102
+ backing_path.display()
1103
+ ))
1104
+ } else {
1105
+ LifecycleError::StatePersist(error.to_string())
1106
+ });
1107
+ }
1108
+ };
1109
+ if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
1110
+ drop(file);
1111
+ let _ = fs::remove_file(&backing_path);
1112
+ cleanup_created_dirs(&created_dirs);
1113
+ return Err(LifecycleError::StatePersist(error.to_string()));
1114
+ }
1115
+ drop(file);
1116
+ let stored = match fs::read(&backing_path) {
1117
+ Ok(stored) => stored,
1118
+ Err(error) => {
1119
+ let _ = fs::remove_file(&backing_path);
1120
+ cleanup_created_dirs(&created_dirs);
1121
+ return Err(LifecycleError::StatePersist(error.to_string()));
1122
+ }
1123
+ };
1124
+ if stored != bytes {
1125
+ let _ = fs::remove_file(&backing_path);
1126
+ cleanup_created_dirs(&created_dirs);
1127
+ return Err(LifecycleError::RequirementUnmet(
1128
+ "Pi fork backing changed before registration".to_string(),
1129
+ ));
1130
+ }
1131
+ if let Err(error) = validate_session_bytes(&stored, session_id, cwd, Some(parent_session)) {
1132
+ let _ = fs::remove_file(&backing_path);
1133
+ cleanup_created_dirs(&created_dirs);
1134
+ return Err(error);
1135
+ }
1136
+ let identity = match file_identity(&backing_path) {
1137
+ Ok(identity) => identity,
1138
+ Err(error) => {
1139
+ let _ = fs::remove_file(&backing_path);
1140
+ cleanup_created_dirs(&created_dirs);
1141
+ return Err(LifecycleError::StatePersist(error));
1142
+ }
1143
+ };
1144
+ let sha256 = format!("{:x}", Sha256::digest(&bytes));
1145
+ Ok(StagedPiSession {
1146
+ seat_paths: paths.clone(),
1147
+ backing_path,
1148
+ bytes,
1149
+ identity,
1150
+ created_dirs,
1151
+ wrapper_identity: None,
1152
+ session_id: session_id.clone(),
1153
+ captured_at: timestamp.to_string(),
1154
+ sha256,
1155
+ })
1156
+ }
1157
+
1158
+ fn create_target_session_dirs(
1159
+ workspace: &Path,
1160
+ paths: &crate::lifecycle::launch::pi_mcp::PiSeatPaths,
1161
+ ) -> Result<Vec<PathBuf>, LifecycleError> {
1162
+ let relative = paths
1163
+ .sessions
1164
+ .strip_prefix(workspace)
1165
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
1166
+ let mut current = workspace.to_path_buf();
1167
+ let mut created = Vec::new();
1168
+ for component in relative.components() {
1169
+ let Component::Normal(name) = component else {
1170
+ cleanup_created_dirs(&created);
1171
+ return Err(LifecycleError::StatePersist(
1172
+ "target Pi session root contains an invalid path component".to_string(),
1173
+ ));
1174
+ };
1175
+ current.push(name);
1176
+ match fs::symlink_metadata(&current) {
1177
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
1178
+ cleanup_created_dirs(&created);
1179
+ return Err(LifecycleError::RequirementUnmet(format!(
1180
+ "target Pi session path is not a real directory: {}",
1181
+ current.display()
1182
+ )));
1183
+ }
1184
+ Ok(_) => {}
1185
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1186
+ if let Err(error) = fs::create_dir(&current) {
1187
+ cleanup_created_dirs(&created);
1188
+ return Err(LifecycleError::StatePersist(error.to_string()));
1189
+ }
1190
+ created.push(current.clone());
1191
+ if let Err(error) = fs::set_permissions(&current, fs::Permissions::from_mode(0o700))
1192
+ {
1193
+ cleanup_created_dirs(&created);
1194
+ return Err(LifecycleError::StatePersist(error.to_string()));
1195
+ }
1196
+ }
1197
+ Err(error) => {
1198
+ cleanup_created_dirs(&created);
1199
+ return Err(LifecycleError::StatePersist(error.to_string()));
1200
+ }
1201
+ }
1202
+ }
1203
+ let canonical = match fs::canonicalize(&paths.sessions) {
1204
+ Ok(canonical) => canonical,
1205
+ Err(error) => {
1206
+ cleanup_created_dirs(&created);
1207
+ return Err(LifecycleError::StatePersist(error.to_string()));
1208
+ }
1209
+ };
1210
+ if canonical != paths.sessions {
1211
+ cleanup_created_dirs(&created);
1212
+ return Err(LifecycleError::RequirementUnmet(
1213
+ "target Pi session root resolves outside its managed path".to_string(),
1214
+ ));
1215
+ }
1216
+ Ok(created)
1217
+ }
1218
+
1219
+ fn session_id_in_use(state: &JsonValue, session_id: &SessionId) -> bool {
1220
+ match state {
1221
+ JsonValue::Object(values) => values.iter().any(|(key, value)| {
1222
+ (matches!(key.as_str(), "session_id" | "_pending_session_id")
1223
+ && value.as_str() == Some(session_id.as_str()))
1224
+ || session_id_in_use(value, session_id)
1225
+ }),
1226
+ JsonValue::Array(values) => values
1227
+ .iter()
1228
+ .any(|value| session_id_in_use(value, session_id)),
1229
+ _ => false,
1230
+ }
1231
+ }
1232
+
1233
+ fn required_state_string<'a>(state: &'a JsonValue, key: &str) -> Result<&'a str, LifecycleError> {
1234
+ state
1235
+ .get(key)
1236
+ .and_then(JsonValue::as_str)
1237
+ .filter(|value| !value.is_empty())
1238
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("Pi source {key} is missing")))
1239
+ }
1240
+
1241
+ fn file_identity(path: &Path) -> Result<FileIdentity, String> {
1242
+ let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
1243
+ if !metadata.file_type().is_file() {
1244
+ return Err(format!("not a regular file: {}", path.display()));
1245
+ }
1246
+ Ok(identity_from_metadata(&metadata))
1247
+ }
1248
+
1249
+ fn identity_from_metadata(metadata: &fs::Metadata) -> FileIdentity {
1250
+ FileIdentity {
1251
+ device: metadata.dev(),
1252
+ inode: metadata.ino(),
1253
+ }
1254
+ }
1255
+
1256
+ fn validate_path_component(value: &str, label: &str) -> Result<(), LifecycleError> {
1257
+ let mut components = Path::new(value).components();
1258
+ if value.is_empty()
1259
+ || value
1260
+ .chars()
1261
+ .any(|character| matches!(character, '/' | '\\' | '\0'))
1262
+ || !matches!(components.next(), Some(Component::Normal(_)))
1263
+ || components.next().is_some()
1264
+ {
1265
+ return Err(LifecycleError::RequirementUnmet(format!(
1266
+ "{label} is not a safe path component"
1267
+ )));
1268
+ }
1269
+ Ok(())
1270
+ }
1271
+
1272
+ fn ensure_no_symlink_components(path: &Path, allow_missing: bool) -> Result<(), LifecycleError> {
1273
+ if !path.is_absolute()
1274
+ || path
1275
+ .components()
1276
+ .any(|component| matches!(component, Component::ParentDir))
1277
+ {
1278
+ return Err(LifecycleError::RequirementUnmet(format!(
1279
+ "path is not a normalized absolute path: {}",
1280
+ path.display()
1281
+ )));
1282
+ }
1283
+ let mut current = PathBuf::new();
1284
+ for component in path.components() {
1285
+ current.push(component.as_os_str());
1286
+ match fs::symlink_metadata(&current) {
1287
+ Ok(metadata) if metadata.file_type().is_symlink() => {
1288
+ return Err(LifecycleError::RequirementUnmet(format!(
1289
+ "symlink path component is not allowed: {}",
1290
+ current.display()
1291
+ )))
1292
+ }
1293
+ Ok(_) => {}
1294
+ Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {
1295
+ return Ok(())
1296
+ }
1297
+ Err(error) => return Err(LifecycleError::RequirementUnmet(error.to_string())),
1298
+ }
1299
+ }
1300
+ Ok(())
1301
+ }
1302
+
1303
+ fn refuse_existing_path(path: &Path, label: &str) -> Result<(), LifecycleError> {
1304
+ match fs::symlink_metadata(path) {
1305
+ Ok(_) => Err(LifecycleError::RequirementUnmet(format!(
1306
+ "{label} already exists: {}",
1307
+ path.display()
1308
+ ))),
1309
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1310
+ Err(error) => Err(LifecycleError::StatePersist(error.to_string())),
1311
+ }
1312
+ }
1313
+
1314
+ fn read_optional_bytes(path: &Path) -> Result<Option<Vec<u8>>, LifecycleError> {
1315
+ match fs::read(path) {
1316
+ Ok(bytes) => Ok(Some(bytes)),
1317
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1318
+ Err(error) => Err(LifecycleError::StatePersist(error.to_string())),
1319
+ }
1320
+ }
1321
+
1322
+ fn spec_has_target(spec: &YamlValue, target: &str) -> bool {
1323
+ if !spec.is_map() {
1324
+ return true;
1325
+ }
1326
+ if spec
1327
+ .get("agents")
1328
+ .and_then(YamlValue::as_list)
1329
+ .is_some_and(|agents| {
1330
+ agents
1331
+ .iter()
1332
+ .any(|agent| yaml_agent_id(agent) == Some(target))
1333
+ })
1334
+ {
1335
+ return true;
1336
+ }
1337
+ spec.get("routing")
1338
+ .and_then(|routing| routing.get("rules"))
1339
+ .and_then(YamlValue::as_list)
1340
+ .is_some_and(|rules| rules.iter().any(|rule| yaml_route_mentions(rule, target)))
1341
+ }
1342
+
1343
+ fn yaml_agent_id(agent: &YamlValue) -> Option<&str> {
1344
+ agent.get("id").and_then(YamlValue::as_str)
1345
+ }
1346
+
1347
+ fn yaml_route_mentions(rule: &YamlValue, target: &str) -> bool {
1348
+ rule.get("assign_to").and_then(YamlValue::as_str) == Some(target)
1349
+ || rule
1350
+ .get("match")
1351
+ .and_then(|matcher| matcher.get("assignee"))
1352
+ .and_then(YamlValue::as_list)
1353
+ .is_some_and(|assignees| {
1354
+ assignees
1355
+ .iter()
1356
+ .any(|assignee| assignee.as_str() == Some(target))
1357
+ })
1358
+ }
1359
+
1360
+ fn compensate_fork(
1361
+ workspace: &Path,
1362
+ team_key: &str,
1363
+ target: &AgentId,
1364
+ source: &AgentId,
1365
+ transport: &dyn Transport,
1366
+ session: &SessionName,
1367
+ panes_before: &BTreeSet<String>,
1368
+ spawn_attempted: bool,
1369
+ spawned: Option<&super::common::SpawnedAgentWindow>,
1370
+ spec_registration: Option<&SpecRegistration>,
1371
+ state_registered: bool,
1372
+ staged: Option<&StagedPiSession>,
1373
+ role: Option<&mut MaterializedRole>,
1374
+ ) -> String {
1375
+ if let Some(staged) = staged {
1376
+ if let Err(error) = verify_no_target_writer(
1377
+ transport,
1378
+ session,
1379
+ target,
1380
+ panes_before,
1381
+ spawn_attempted,
1382
+ spawned,
1383
+ ) {
1384
+ if let Some(role) = role {
1385
+ role.keep();
1386
+ }
1387
+ return error;
1388
+ }
1389
+ match read_optional_bytes(&staged.backing_path) {
1390
+ Ok(Some(bytes)) if bytes == staged.bytes => {}
1391
+ Ok(Some(_)) => {
1392
+ if let Some(role) = role {
1393
+ role.keep();
1394
+ }
1395
+ return format!(
1396
+ "target Pi session has post-snapshot records; preserved {}",
1397
+ staged.backing_path.display()
1398
+ );
1399
+ }
1400
+ Ok(None) => {}
1401
+ Err(error) => {
1402
+ if let Some(role) = role {
1403
+ role.keep();
1404
+ }
1405
+ return format!("cannot verify target Pi backing before cleanup: {error}");
1406
+ }
1407
+ }
1408
+ } else if spawn_attempted {
1409
+ if let Err(error) = verify_no_target_writer(
1410
+ transport,
1411
+ session,
1412
+ target,
1413
+ panes_before,
1414
+ spawn_attempted,
1415
+ spawned,
1416
+ ) {
1417
+ if let Some(role) = role {
1418
+ role.keep();
1419
+ }
1420
+ return error;
1421
+ }
1422
+ }
1423
+
1424
+ if state_registered {
1425
+ let Some((staged, role_ref)) = staged.zip(role.as_deref()) else {
1426
+ if let Some(role) = role {
1427
+ role.keep();
1428
+ }
1429
+ return "fork state identity is unavailable; resources preserved".to_string();
1430
+ };
1431
+ if let Err(error) = rollback_fork_state(
1432
+ workspace,
1433
+ team_key,
1434
+ target,
1435
+ source,
1436
+ &staged.session_id,
1437
+ &staged.backing_path,
1438
+ role_ref.path(),
1439
+ ) {
1440
+ if let Some(role) = role {
1441
+ role.keep();
1442
+ }
1443
+ return format!("state rollback failed: {error}");
1444
+ }
1445
+ }
1446
+ if let Some(registration) = spec_registration {
1447
+ if let Err(error) = rollback_fork_spec(registration, target.as_str()) {
1448
+ if let Some(role) = role {
1449
+ role.keep();
1450
+ }
1451
+ return format!("spec rollback failed: {error}");
1452
+ }
1453
+ }
1454
+ if let Some(staged) = staged {
1455
+ if let Err(error) = remove_staged_session(staged, spawn_attempted) {
1456
+ if let Some(role) = role {
1457
+ role.keep();
1458
+ }
1459
+ return error;
1460
+ }
1461
+ }
1462
+ String::new()
1463
+ }
1464
+
1465
+ fn verify_no_target_writer(
1466
+ transport: &dyn Transport,
1467
+ session: &SessionName,
1468
+ target: &AgentId,
1469
+ panes_before: &BTreeSet<String>,
1470
+ spawn_attempted: bool,
1471
+ spawned: Option<&super::common::SpawnedAgentWindow>,
1472
+ ) -> Result<(), String> {
1473
+ let pane_id = if let Some(spawned) = spawned {
1474
+ spawned.spawn.pane_id.clone()
1475
+ } else if spawn_attempted {
1476
+ let targets = transport
1477
+ .list_targets()
1478
+ .map_err(|error| error.to_string())?;
1479
+ let new_panes: Vec<_> = targets
1480
+ .iter()
1481
+ .filter(|pane| !panes_before.contains(pane.pane_id.as_str()))
1482
+ .collect();
1483
+ match new_panes.as_slice() {
1484
+ [] => return Ok(()),
1485
+ [pane]
1486
+ if pane.session.as_str() == session.as_str()
1487
+ && pane.window_name.as_ref().map(WindowName::as_str)
1488
+ == Some(target.as_str()) =>
1489
+ {
1490
+ pane.pane_id.clone()
1491
+ }
1492
+ _ => return Err("new pane ownership is ambiguous; resources preserved".to_string()),
1493
+ }
1494
+ } else {
1495
+ return Ok(());
1496
+ };
1497
+ let targets = transport
1498
+ .list_targets()
1499
+ .map_err(|error| error.to_string())?;
1500
+ let exact = targets.iter().any(|pane| {
1501
+ pane.pane_id == pane_id
1502
+ && pane.session.as_str() == session.as_str()
1503
+ && pane.window_name.as_ref().map(WindowName::as_str) == Some(target.as_str())
1504
+ });
1505
+ if !exact {
1506
+ let still_present = targets.iter().any(|pane| pane.pane_id == pane_id);
1507
+ if !panes_before.contains(pane_id.as_str()) {
1508
+ transport
1509
+ .kill_pane(&pane_id)
1510
+ .map_err(|error| format!("could not stop newly spawned pane: {error}"))?;
1511
+ let remaining = transport
1512
+ .list_targets()
1513
+ .map_err(|error| error.to_string())?;
1514
+ if remaining.iter().any(|pane| pane.pane_id == pane_id) {
1515
+ return Err(
1516
+ "newly spawned pane remains after kill; resources preserved".to_string()
1517
+ );
1518
+ }
1519
+ return Ok(());
1520
+ }
1521
+ if still_present {
1522
+ return Err(
1523
+ "spawned pane is not owned by the target window; resources preserved".to_string(),
1524
+ );
1525
+ }
1526
+ return Ok(());
1527
+ }
1528
+ transport
1529
+ .kill_pane(&pane_id)
1530
+ .map_err(|error| format!("could not stop exact target pane: {error}"))?;
1531
+ let remaining = transport
1532
+ .list_targets()
1533
+ .map_err(|error| error.to_string())?;
1534
+ if remaining.iter().any(|pane| pane.pane_id == pane_id) {
1535
+ return Err("exact target pane remains after kill; resources preserved".to_string());
1536
+ }
1537
+ Ok(())
1538
+ }
1539
+
1540
+ fn fork_target_state_registered(
1541
+ workspace: &Path,
1542
+ team_key: &str,
1543
+ target: &AgentId,
1544
+ source: &AgentId,
1545
+ session_id: &SessionId,
1546
+ backing_path: &Path,
1547
+ role_path: &Path,
1548
+ ) -> bool {
1549
+ let Ok(state) = crate::state::projection::select_runtime_state(workspace, Some(team_key))
1550
+ else {
1551
+ return false;
1552
+ };
1553
+ let Some(agent) = state
1554
+ .get("agents")
1555
+ .and_then(|agents| agents.get(target.as_str()))
1556
+ else {
1557
+ return false;
1558
+ };
1559
+ let backing_text = backing_path.to_string_lossy().to_string();
1560
+ let role_text = role_path.to_string_lossy().to_string();
1561
+ agent.get("owner_team_id").and_then(JsonValue::as_str) == Some(team_key)
1562
+ && agent.get("forked_from").and_then(JsonValue::as_str) == Some(source.as_str())
1563
+ && agent.get("session_id").and_then(JsonValue::as_str) == Some(session_id.as_str())
1564
+ && agent.get("rollout_path").and_then(JsonValue::as_str) == Some(backing_text.as_str())
1565
+ && agent.get("dynamic_role_file").and_then(JsonValue::as_str) == Some(role_text.as_str())
1566
+ && agent.get("captured_via").and_then(JsonValue::as_str) == Some("fork_snapshot")
1567
+ && agent.get("capture_state").and_then(JsonValue::as_str) == Some("captured")
1568
+ }
1569
+
1570
+ fn rollback_fork_state(
1571
+ workspace: &Path,
1572
+ team_key: &str,
1573
+ target: &AgentId,
1574
+ source: &AgentId,
1575
+ session_id: &SessionId,
1576
+ backing_path: &Path,
1577
+ role_path: &Path,
1578
+ ) -> Result<(), String> {
1579
+ let mut state = crate::state::projection::select_runtime_state(workspace, Some(team_key))
1580
+ .map_err(|error| error.to_string())?;
1581
+ let agent = state
1582
+ .get("agents")
1583
+ .and_then(|agents| agents.get(target.as_str()))
1584
+ .ok_or_else(|| "fork target row disappeared before rollback".to_string())?;
1585
+ let backing_text = backing_path.to_string_lossy().to_string();
1586
+ let role_text = role_path.to_string_lossy().to_string();
1587
+ if agent.get("owner_team_id").and_then(JsonValue::as_str) != Some(team_key)
1588
+ || agent.get("forked_from").and_then(JsonValue::as_str) != Some(source.as_str())
1589
+ || agent.get("session_id").and_then(JsonValue::as_str) != Some(session_id.as_str())
1590
+ || agent.get("rollout_path").and_then(JsonValue::as_str) != Some(backing_text.as_str())
1591
+ || agent.get("dynamic_role_file").and_then(JsonValue::as_str) != Some(role_text.as_str())
1592
+ {
1593
+ return Err("fork target row identity changed; left untouched".to_string());
1594
+ }
1595
+ state
1596
+ .get_mut("agents")
1597
+ .and_then(JsonValue::as_object_mut)
1598
+ .ok_or_else(|| "runtime agents map is missing".to_string())?
1599
+ .remove(target.as_str());
1600
+ crate::state::repository::StateRepository::new(workspace)
1601
+ .save(
1602
+ crate::state::repository::StateWriteIntent::AgentRollback {
1603
+ team_key: Some(team_key),
1604
+ agent_id: target.as_str(),
1605
+ },
1606
+ &state,
1607
+ )
1608
+ .map_err(|error| error.to_string())
1609
+ }
1610
+
1611
+ fn rollback_fork_spec(registration: &SpecRegistration, target: &str) -> Result<(), String> {
1612
+ ensure_no_symlink_components(&registration.path, true).map_err(|error| error.to_string())?;
1613
+ let current = read_optional_bytes(&registration.path).map_err(|error| error.to_string())?;
1614
+ if current.as_deref() == registration.previous_bytes.as_deref() {
1615
+ return Ok(());
1616
+ }
1617
+ if current.as_deref() == Some(registration.written_bytes.as_slice())
1618
+ && registration.identity.is_some()
1619
+ && file_identity(&registration.path).ok() == registration.identity
1620
+ {
1621
+ return match &registration.previous_bytes {
1622
+ Some(bytes) => write_bytes_atomic(&registration.path, bytes),
1623
+ None => fs::remove_file(&registration.path).map_err(|error| error.to_string()),
1624
+ };
1625
+ }
1626
+ let Some(bytes) = current else {
1627
+ return if registration.previous_bytes.is_none() {
1628
+ Ok(())
1629
+ } else {
1630
+ Err("runtime spec disappeared during rollback".to_string())
1631
+ };
1632
+ };
1633
+ let text = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?;
1634
+ let mut spec = yaml::loads(text).map_err(|error| error.to_string())?;
1635
+ remove_target_from_spec(&mut spec, target, &registration.target_agent);
1636
+ if spec_has_target(&spec, target) {
1637
+ return Err("runtime spec target changed during operation; left untouched".to_string());
1638
+ }
1639
+ let bytes = yaml::dumps(&spec).into_bytes();
1640
+ write_bytes_atomic(&registration.path, &bytes)
1641
+ }
1642
+
1643
+ fn remove_target_from_spec(spec: &mut YamlValue, target: &str, expected_agent: &YamlValue) {
1644
+ let YamlValue::Map(root) = spec else {
1645
+ return;
1646
+ };
1647
+ if let Some((_, YamlValue::List(agents))) = root.iter_mut().find(|(key, _)| key == "agents") {
1648
+ agents.retain(|agent| yaml_agent_id(agent) != Some(target) || agent != expected_agent);
1649
+ }
1650
+ if let Some((_, YamlValue::Map(fields))) = root.iter_mut().find(|(key, _)| key == "routing") {
1651
+ if let Some((_, YamlValue::List(rules))) = fields.iter_mut().find(|(key, _)| key == "rules")
1652
+ {
1653
+ let route_id = format!("route-{target}");
1654
+ let expected_route = YamlValue::Map(vec![
1655
+ ("id".to_string(), YamlValue::Str(route_id.clone())),
1656
+ (
1657
+ "match".to_string(),
1658
+ YamlValue::Map(vec![(
1659
+ "assignee".to_string(),
1660
+ YamlValue::List(vec![YamlValue::Str(target.to_string())]),
1661
+ )]),
1662
+ ),
1663
+ ("assign_to".to_string(), YamlValue::Str(target.to_string())),
1664
+ ("priority".to_string(), YamlValue::Int(10)),
1665
+ ]);
1666
+ rules.retain(|rule| {
1667
+ !(rule.get("id").and_then(YamlValue::as_str) == Some(route_id.as_str())
1668
+ && rule.get("assign_to").and_then(YamlValue::as_str) == Some(target)
1669
+ && rule == &expected_route)
1670
+ });
1671
+ }
1672
+ }
1673
+ }
1674
+
1675
+ fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
1676
+ let parent = path
1677
+ .parent()
1678
+ .ok_or_else(|| "runtime spec path has no parent".to_string())?;
1679
+ let nonce = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1680
+ let temp = parent.join(format!(
1681
+ ".fork-spec-rollback-{}-{nonce}",
1682
+ std::process::id()
1683
+ ));
1684
+ let mut file = OpenOptions::new()
1685
+ .write(true)
1686
+ .create_new(true)
1687
+ .mode(0o600)
1688
+ .open(&temp)
1689
+ .map_err(|error| error.to_string())?;
1690
+ if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1691
+ drop(file);
1692
+ let _ = fs::remove_file(&temp);
1693
+ return Err(error.to_string());
1694
+ }
1695
+ drop(file);
1696
+ if let Err(error) = fs::rename(&temp, path) {
1697
+ let _ = fs::remove_file(&temp);
1698
+ return Err(error.to_string());
1699
+ }
1700
+ Ok(())
1701
+ }
1702
+
1703
+ fn capture_target_wrapper_identity(staged: Option<&mut StagedPiSession>) {
1704
+ if let Some(staged) = staged {
1705
+ staged.wrapper_identity = ensure_no_symlink_components(&staged.seat_paths.wrapper, false)
1706
+ .ok()
1707
+ .and_then(|_| file_identity(&staged.seat_paths.wrapper).ok());
1708
+ }
1709
+ }
1710
+
1711
+ fn remove_staged_session(staged: &StagedPiSession, remove_wrapper: bool) -> Result<(), String> {
1712
+ ensure_no_symlink_components(&staged.backing_path, true).map_err(|error| error.to_string())?;
1713
+ match fs::symlink_metadata(&staged.backing_path) {
1714
+ Ok(_) => {
1715
+ if file_identity(&staged.backing_path).ok() != Some(staged.identity)
1716
+ || read_optional_bytes(&staged.backing_path)
1717
+ .map_err(|error| error.to_string())?
1718
+ .as_deref()
1719
+ != Some(staged.bytes.as_slice())
1720
+ {
1721
+ return Err(format!(
1722
+ "target Pi backing is no longer the staged file; preserved {}",
1723
+ staged.backing_path.display()
1724
+ ));
1725
+ }
1726
+ fs::remove_file(&staged.backing_path).map_err(|error| error.to_string())?;
1727
+ }
1728
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1729
+ Err(error) => return Err(error.to_string()),
1730
+ }
1731
+ if remove_wrapper {
1732
+ ensure_no_symlink_components(&staged.seat_paths.runtime_root, false)
1733
+ .map_err(|error| error.to_string())?;
1734
+ match fs::symlink_metadata(&staged.seat_paths.wrapper) {
1735
+ Ok(metadata)
1736
+ if metadata.file_type().is_file()
1737
+ && staged.wrapper_identity == Some(identity_from_metadata(&metadata)) =>
1738
+ {
1739
+ fs::remove_file(&staged.seat_paths.wrapper).map_err(|error| error.to_string())?;
1740
+ }
1741
+ Ok(_) => return Err("target Pi wrapper is not a regular file; preserved".to_string()),
1742
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1743
+ Err(error) => return Err(error.to_string()),
1744
+ }
1745
+ }
1746
+ cleanup_created_dirs_checked(&staged.created_dirs)
1747
+ }
1748
+
1749
+ fn cleanup_created_dirs(dirs: &[PathBuf]) {
1750
+ for dir in dirs.iter().rev() {
1751
+ let _ = fs::remove_dir(dir);
1752
+ }
1753
+ }
1754
+
1755
+ fn cleanup_created_dirs_checked(dirs: &[PathBuf]) -> Result<(), String> {
1756
+ for dir in dirs.iter().rev() {
1757
+ match fs::remove_dir(dir) {
1758
+ Ok(()) => {}
1759
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1760
+ Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
1761
+ return Err(format!(
1762
+ "target Pi runtime directory is not empty; preserved {}",
1763
+ dir.display()
1764
+ ))
1765
+ }
1766
+ Err(error) => return Err(error.to_string()),
1767
+ }
1768
+ }
1769
+ Ok(())
1770
+ }
1771
+
1772
+ fn write_fork_audit(
1773
+ workspace: &Path,
1774
+ fields: &mut JsonValue,
1775
+ outcome: &str,
1776
+ failure_stage: &str,
1777
+ compensation: &str,
1778
+ ) -> Result<(), String> {
1779
+ fields["outcome"] = serde_json::json!(outcome);
1780
+ fields["failure_stage"] = if outcome == "succeeded" {
1781
+ JsonValue::Null
1782
+ } else {
1783
+ serde_json::json!(failure_stage)
1784
+ };
1785
+ fields["compensation"] = serde_json::json!(compensation);
1786
+ crate::event_log::EventLog::new(workspace)
1787
+ .write(crate::lifecycle::event_names::CONTEXT_FORK, fields.clone())
1788
+ .map(|_| ())
1789
+ .map_err(|error| error.to_string())
1790
+ }
1791
+
1792
+ fn state_has_session_name(state: &JsonValue) -> bool {
1793
+ state
1794
+ .get("session_name")
1795
+ .and_then(JsonValue::as_str)
1796
+ .is_some_and(|value| !value.is_empty())
1797
+ || state
1798
+ .get("leader_receiver")
1799
+ .and_then(|receiver| receiver.get("session_name"))
1800
+ .and_then(JsonValue::as_str)
1801
+ .is_some_and(|value| !value.is_empty())
1802
+ }
1803
+
1804
+ fn session_name_for_audit(state: &JsonValue) -> SessionName {
1805
+ state_session_name(state)
1806
+ }