@team-agent/installer 0.5.99 → 0.5.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -156
- package/crates/team-agent/src/cli/diagnose.rs +32 -10
- package/crates/team-agent/src/cli/emit.rs +6 -185
- package/crates/team-agent/src/cli/mod.rs +32 -182
- package/crates/team-agent/src/cli/send/coordinator.rs +2 -2
- package/crates/team-agent/src/cli/spec.rs +41 -67
- package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -185
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +9 -9
- package/crates/team-agent/src/cli/triage.rs +1 -1
- package/crates/team-agent/src/cli/types.rs +0 -49
- package/crates/team-agent/src/compiler.rs +3 -0
- package/crates/team-agent/src/leader/lease.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +211 -16
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +15 -5
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +89 -6
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +168 -32
- package/crates/team-agent/src/lifecycle/launch.rs +11 -3
- package/crates/team-agent/src/lifecycle/profile_launch.rs +23 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +2 -2
- package/crates/team-agent/src/lifecycle/restart/avatar.rs +1806 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +114 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +14 -8
- package/crates/team-agent/src/lifecycle/restart/selection.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart.rs +11 -4
- package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +9 -2
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1398 -0
- package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +0 -60
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +10 -2
- package/crates/team-agent/src/messaging/send.rs +2 -2
- package/crates/team-agent/src/model/spec.rs +1 -0
- package/crates/team-agent/src/provider/types.rs +3 -1
- package/crates/team-agent/src/state/persist.rs +58 -2
- package/crates/team-agent/src/state/projection.rs +5 -1
- package/crates/team-agent/src/state/repository.rs +125 -9
- package/crates/team-agent/src/transport/test_support.rs +23 -3
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +17 -46
- package/skills/team-agent/references/recovery-runbook.md +2 -2
- package/skills/team-agent/references/team-agent-operator.md +4 -2
- package/skills/team-agent/references/team-in-team.md +0 -6
|
@@ -0,0 +1,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(¤t, 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(¤t) {
|
|
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(¤t) {
|
|
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(¤t, 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(¤t) {
|
|
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(®istration.path, true).map_err(|error| error.to_string())?;
|
|
1613
|
+
let current = read_optional_bytes(®istration.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(®istration.path).ok() == registration.identity
|
|
1620
|
+
{
|
|
1621
|
+
return match ®istration.previous_bytes {
|
|
1622
|
+
Some(bytes) => write_bytes_atomic(®istration.path, bytes),
|
|
1623
|
+
None => fs::remove_file(®istration.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, ®istration.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(®istration.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
|
+
}
|