@team-agent/installer 0.5.57 → 0.5.58
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/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +89 -95
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +104 -0
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +49 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +1 -0
- package/crates/team-agent/src/provider/session/capture.rs +37 -1
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +85 -0
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +65 -0
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +138 -0
- package/crates/team-agent/src/provider/session/context_fork.rs +30 -135
- package/crates/team-agent/src/provider/session/mod.rs +2 -1
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -2484,6 +2484,7 @@ pub mod lifecycle_port {
|
|
|
2484
2484
|
"new_agent_id": report.new_agent_id.as_str(),
|
|
2485
2485
|
"session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
2486
2486
|
"new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
2487
|
+
"backing_state": report.backing_state,
|
|
2487
2488
|
})),
|
|
2488
2489
|
Err(e) => Ok(error_value(e)),
|
|
2489
2490
|
}
|
|
@@ -57,66 +57,13 @@ pub fn fork_agent_with_transport(
|
|
|
57
57
|
let text = std::fs::read_to_string(&read_spec_path)
|
|
58
58
|
.map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
|
|
59
59
|
let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
60
|
-
if
|
|
60
|
+
if fork_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
|
|
61
61
|
return Err(LifecycleError::RequirementUnmet(format!(
|
|
62
62
|
"agent id already exists: {as_agent_id}"
|
|
63
63
|
)));
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
})?;
|
|
68
|
-
// Fork requires the complete source tuple before treating session_id as
|
|
69
|
-
// resumable truth; a scalar-only row has no confirmed backing.
|
|
70
|
-
let source_agent_state = state
|
|
71
|
-
.get("agents")
|
|
72
|
-
.and_then(|v| v.get(source_agent_id.as_str()))
|
|
73
|
-
.ok_or_else(|| {
|
|
74
|
-
LifecycleError::Provider(format!(
|
|
75
|
-
"cannot fork {source_agent_id}: source agent row not in state"
|
|
76
|
-
))
|
|
77
|
-
})?;
|
|
78
|
-
let tuple_field_ok = |field: &str| -> bool {
|
|
79
|
-
source_agent_state
|
|
80
|
-
.get(field)
|
|
81
|
-
.and_then(|v| v.as_str())
|
|
82
|
-
.is_some_and(|s| !s.is_empty())
|
|
83
|
-
};
|
|
84
|
-
let session_id_str = source_agent_state
|
|
85
|
-
.get("session_id")
|
|
86
|
-
.and_then(|v| v.as_str())
|
|
87
|
-
.filter(|s| !s.is_empty());
|
|
88
|
-
let rollout_path_str = source_agent_state
|
|
89
|
-
.get("rollout_path")
|
|
90
|
-
.and_then(|v| v.as_str())
|
|
91
|
-
.filter(|s| !s.is_empty());
|
|
92
|
-
if session_id_str.is_none()
|
|
93
|
-
|| rollout_path_str.is_none()
|
|
94
|
-
|| !tuple_field_ok("captured_at")
|
|
95
|
-
|| !tuple_field_ok("captured_via")
|
|
96
|
-
{
|
|
97
|
-
return Err(LifecycleError::Provider(format!(
|
|
98
|
-
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
99
|
-
(session_id+rollout_path+captured_at+captured_via required)"
|
|
100
|
-
)));
|
|
101
|
-
}
|
|
102
|
-
let Some(source_backing_raw) = rollout_path_str else {
|
|
103
|
-
return Err(LifecycleError::Provider(format!(
|
|
104
|
-
"cannot fork {source_agent_id}: source session backing is missing"
|
|
105
|
-
)));
|
|
106
|
-
};
|
|
107
|
-
let source_backing = Path::new(source_backing_raw);
|
|
108
|
-
if !source_backing.is_file() {
|
|
109
|
-
return Err(LifecycleError::Provider(format!(
|
|
110
|
-
"cannot fork {source_agent_id}: source session backing is not readable: {}",
|
|
111
|
-
source_backing.display()
|
|
112
|
-
)));
|
|
113
|
-
}
|
|
114
|
-
let Some(source_session_id) = session_id_str else {
|
|
115
|
-
return Err(LifecycleError::Provider(format!(
|
|
116
|
-
"cannot fork {source_agent_id}: source session id is missing"
|
|
117
|
-
)));
|
|
118
|
-
};
|
|
119
|
-
let session_id = crate::provider::SessionId::new(source_session_id.to_string());
|
|
65
|
+
// Source existence authority: state.get("agents"), matching clone-agent.
|
|
66
|
+
let (session_id, source_backing) = fork_source_tuple(&state, source_agent_id)?;
|
|
120
67
|
let session_name = state
|
|
121
68
|
.get("session_name")
|
|
122
69
|
.and_then(|v| v.as_str())
|
|
@@ -158,7 +105,7 @@ pub fn fork_agent_with_transport(
|
|
|
158
105
|
crate::model::spec::validate_spec(&new_spec, &validate_ws)
|
|
159
106
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
160
107
|
write_spec_atomic(&spec_path, &new_spec)?;
|
|
161
|
-
let new_agent =
|
|
108
|
+
let new_agent = fork_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
|
|
162
109
|
LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
|
|
163
110
|
})?;
|
|
164
111
|
let provider = new_agent
|
|
@@ -296,13 +243,21 @@ pub fn fork_agent_with_transport(
|
|
|
296
243
|
plan.provider_projects_root = source_backing.parent().map(Path::to_path_buf);
|
|
297
244
|
}
|
|
298
245
|
let window = WindowName::new(as_agent_id.as_str());
|
|
246
|
+
let mut claude_fork = prepare_claude_fork_backing(
|
|
247
|
+
provider,
|
|
248
|
+
&plan,
|
|
249
|
+
&source_backing,
|
|
250
|
+
&session_id,
|
|
251
|
+
)
|
|
252
|
+
.map_err(|error| {
|
|
253
|
+
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
254
|
+
cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
|
|
255
|
+
error
|
|
256
|
+
})?;
|
|
257
|
+
// The framework-created Claude snapshot is only fork input, not provider
|
|
258
|
+
// proof. Observe changes made after materialization so a spawn-only
|
|
259
|
+
// provider cannot turn the copied source backing into a Verified result.
|
|
299
260
|
let backing_before = crate::provider::session::ContextBackingSnapshot::capture(provider, &plan);
|
|
300
|
-
let mut claude_fork = prepare_claude_fork_backing(provider, &plan, source_backing, &session_id)
|
|
301
|
-
.map_err(|error| {
|
|
302
|
-
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
303
|
-
cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
|
|
304
|
-
error
|
|
305
|
-
})?;
|
|
306
261
|
let mut env =
|
|
307
262
|
inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
|
|
308
263
|
apply_profile_launch_env(&mut env, &profile_launch);
|
|
@@ -401,7 +356,7 @@ pub fn fork_agent_with_transport(
|
|
|
401
356
|
})?;
|
|
402
357
|
let convergence_deadline =
|
|
403
358
|
crate::provider::session::context_fork_convergence_deadline(provider);
|
|
404
|
-
let
|
|
359
|
+
let context_outcome = crate::provider::session::observe_context_fork(
|
|
405
360
|
provider,
|
|
406
361
|
&session_id,
|
|
407
362
|
&plan,
|
|
@@ -411,9 +366,40 @@ pub fn fork_agent_with_transport(
|
|
|
411
366
|
&workspace,
|
|
412
367
|
&spawned_at,
|
|
413
368
|
convergence_deadline,
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
|
|
369
|
+
);
|
|
370
|
+
let context_proof = match context_outcome {
|
|
371
|
+
crate::provider::session::ContextForkOutcome::Verified(proof) => Some(proof),
|
|
372
|
+
crate::provider::session::ContextForkOutcome::Pending(pending) => {
|
|
373
|
+
if let Err(error) = finalize_pending_fork_state(ForkPendingFinalizeInput {
|
|
374
|
+
workspace: &workspace,
|
|
375
|
+
team_key: &fork_team,
|
|
376
|
+
source_agent_id,
|
|
377
|
+
agent_id: as_agent_id,
|
|
378
|
+
spec_agent: new_agent,
|
|
379
|
+
safety: &safety,
|
|
380
|
+
plan: &plan,
|
|
381
|
+
profile_launch: &profile_launch,
|
|
382
|
+
spawn: &spawn,
|
|
383
|
+
profile_dir: &profile_dir,
|
|
384
|
+
dynamic_role_file: materialized_role.path(),
|
|
385
|
+
pending: &pending,
|
|
386
|
+
spawn_epoch,
|
|
387
|
+
}) {
|
|
388
|
+
rollback_fork_after_spawn(
|
|
389
|
+
&workspace,
|
|
390
|
+
transport,
|
|
391
|
+
&session_name,
|
|
392
|
+
&window,
|
|
393
|
+
&mcp_config_path,
|
|
394
|
+
as_agent_id,
|
|
395
|
+
&profile_launch,
|
|
396
|
+
&fork_team,
|
|
397
|
+
);
|
|
398
|
+
return Err(error);
|
|
399
|
+
}
|
|
400
|
+
None
|
|
401
|
+
}
|
|
402
|
+
crate::provider::session::ContextForkOutcome::Rejected(error) => {
|
|
417
403
|
rollback_fork_after_spawn(
|
|
418
404
|
&workspace,
|
|
419
405
|
transport,
|
|
@@ -427,33 +413,35 @@ pub fn fork_agent_with_transport(
|
|
|
427
413
|
return Err(LifecycleError::Provider(error.to_string()));
|
|
428
414
|
}
|
|
429
415
|
};
|
|
430
|
-
if let
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
416
|
+
if let Some(context_proof) = context_proof.as_ref() {
|
|
417
|
+
if let Err(error) = finalize_fork_state(ForkFinalizeInput {
|
|
418
|
+
workspace: &workspace,
|
|
419
|
+
team_key: &fork_team,
|
|
420
|
+
source_agent_id,
|
|
421
|
+
agent_id: as_agent_id,
|
|
422
|
+
spec_agent: new_agent,
|
|
423
|
+
safety: &safety,
|
|
424
|
+
plan: &plan,
|
|
425
|
+
profile_launch: &profile_launch,
|
|
426
|
+
spawn: &spawn,
|
|
427
|
+
profile_dir: &profile_dir,
|
|
428
|
+
dynamic_role_file: materialized_role.path(),
|
|
429
|
+
context_proof: &context_proof,
|
|
430
|
+
spawned_at: &spawned_at,
|
|
431
|
+
spawn_epoch,
|
|
432
|
+
}) {
|
|
433
|
+
rollback_fork_after_spawn(
|
|
434
|
+
&workspace,
|
|
435
|
+
transport,
|
|
436
|
+
&session_name,
|
|
437
|
+
&window,
|
|
438
|
+
&mcp_config_path,
|
|
439
|
+
as_agent_id,
|
|
440
|
+
&profile_launch,
|
|
441
|
+
&fork_team,
|
|
442
|
+
);
|
|
443
|
+
return Err(error);
|
|
444
|
+
}
|
|
457
445
|
}
|
|
458
446
|
if let Err(error) =
|
|
459
447
|
verify_fork_registration(&workspace, &fork_team, as_agent_id, &spawn, &window)
|
|
@@ -487,6 +475,11 @@ pub fn fork_agent_with_transport(
|
|
|
487
475
|
if let Some(materialized) = copilot_fork.as_mut() {
|
|
488
476
|
materialized.keep();
|
|
489
477
|
}
|
|
478
|
+
let backing_state = if context_proof.is_some() {
|
|
479
|
+
ForkBackingState::Verified
|
|
480
|
+
} else {
|
|
481
|
+
ForkBackingState::PendingContextFork
|
|
482
|
+
};
|
|
490
483
|
Ok(ForkAgentReport {
|
|
491
484
|
source_agent_id: source_agent_id.clone(),
|
|
492
485
|
new_agent_id: as_agent_id.clone(),
|
|
@@ -495,6 +488,7 @@ pub fn fork_agent_with_transport(
|
|
|
495
488
|
state_file: crate::state::persist::runtime_state_path(&workspace),
|
|
496
489
|
coordinator_started,
|
|
497
490
|
},
|
|
498
|
-
session_id:
|
|
491
|
+
session_id: context_proof.map(|proof| proof.new_session_id),
|
|
492
|
+
backing_state,
|
|
499
493
|
})
|
|
500
494
|
}
|
|
@@ -172,6 +172,110 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
|
|
|
172
172
|
.map_err(|error| LifecycleError::StatePersist(error.to_string()))
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
pub(super) struct ForkPendingFinalizeInput<'a> {
|
|
176
|
+
pub workspace: &'a Path,
|
|
177
|
+
pub team_key: &'a str,
|
|
178
|
+
pub source_agent_id: &'a AgentId,
|
|
179
|
+
pub agent_id: &'a AgentId,
|
|
180
|
+
pub spec_agent: &'a Value,
|
|
181
|
+
pub safety: &'a DangerousApproval,
|
|
182
|
+
pub plan: &'a crate::provider::CommandPlan,
|
|
183
|
+
pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
|
|
184
|
+
pub spawn: &'a crate::transport::SpawnResult,
|
|
185
|
+
pub profile_dir: &'a Path,
|
|
186
|
+
pub dynamic_role_file: &'a Path,
|
|
187
|
+
pub pending: &'a crate::provider::session::PendingContextFork,
|
|
188
|
+
pub spawn_epoch: u64,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pub(super) fn finalize_pending_fork_state(
|
|
192
|
+
input: ForkPendingFinalizeInput<'_>,
|
|
193
|
+
) -> Result<(), LifecycleError> {
|
|
194
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
195
|
+
workspace: input.workspace,
|
|
196
|
+
operation: "fork-agent-pending",
|
|
197
|
+
team: Some(input.team_key),
|
|
198
|
+
agent_id: Some(input.agent_id),
|
|
199
|
+
})?;
|
|
200
|
+
let mut next_state = crate::state::selector::resolve_active_team(
|
|
201
|
+
input.workspace,
|
|
202
|
+
Some(input.team_key),
|
|
203
|
+
crate::state::selector::SelectorMode::RequireSpec,
|
|
204
|
+
)
|
|
205
|
+
.map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
|
|
206
|
+
.state;
|
|
207
|
+
upsert_pending_forked_agent_state(
|
|
208
|
+
&mut next_state,
|
|
209
|
+
input.source_agent_id,
|
|
210
|
+
input.agent_id,
|
|
211
|
+
input.spec_agent,
|
|
212
|
+
input.safety,
|
|
213
|
+
input.plan,
|
|
214
|
+
input.profile_launch,
|
|
215
|
+
input.spawn,
|
|
216
|
+
Some(input.profile_dir),
|
|
217
|
+
input.dynamic_role_file,
|
|
218
|
+
input.pending,
|
|
219
|
+
input.spawn_epoch,
|
|
220
|
+
)?;
|
|
221
|
+
maybe_fail_fork_after_spawn("save_runtime_state")?;
|
|
222
|
+
crate::state::repository::StateRepository::new(input.workspace)
|
|
223
|
+
.save(
|
|
224
|
+
crate::state::repository::StateWriteIntent::ForkAgent {
|
|
225
|
+
team_key: input.team_key,
|
|
226
|
+
agent_id: input.agent_id.as_str(),
|
|
227
|
+
},
|
|
228
|
+
&next_state,
|
|
229
|
+
)
|
|
230
|
+
.map_err(|error| LifecycleError::StatePersist(error.to_string()))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pub(crate) fn finalize_pending_fork_capture(
|
|
234
|
+
agent: &mut serde_json::Map<String, serde_json::Value>,
|
|
235
|
+
captured: &crate::provider::CapturedSession,
|
|
236
|
+
) -> bool {
|
|
237
|
+
let Some(session_id) = captured.session_id.as_ref() else {
|
|
238
|
+
return false;
|
|
239
|
+
};
|
|
240
|
+
let Some(rollout_path) = captured.rollout_path.as_ref() else {
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
if agent
|
|
244
|
+
.get("fork_source_session_id")
|
|
245
|
+
.and_then(serde_json::Value::as_str)
|
|
246
|
+
== Some(session_id.as_str())
|
|
247
|
+
{
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
agent.insert(
|
|
251
|
+
"session_id".to_string(),
|
|
252
|
+
serde_json::json!(session_id.as_str()),
|
|
253
|
+
);
|
|
254
|
+
agent.insert(
|
|
255
|
+
"rollout_path".to_string(),
|
|
256
|
+
serde_json::json!(rollout_path.as_path().to_string_lossy()),
|
|
257
|
+
);
|
|
258
|
+
agent.insert(
|
|
259
|
+
"captured_at".to_string(),
|
|
260
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
261
|
+
);
|
|
262
|
+
agent.insert(
|
|
263
|
+
"captured_via".to_string(),
|
|
264
|
+
serde_json::to_value(captured.captured_via).unwrap_or(serde_json::Value::Null),
|
|
265
|
+
);
|
|
266
|
+
agent.insert(
|
|
267
|
+
"attribution_confidence".to_string(),
|
|
268
|
+
serde_json::to_value(captured.attribution_confidence).unwrap_or(serde_json::Value::Null),
|
|
269
|
+
);
|
|
270
|
+
agent.remove("_pending_session_id");
|
|
271
|
+
agent.remove("attribution_ambiguous");
|
|
272
|
+
agent.remove("fork_source_session_id");
|
|
273
|
+
agent.remove("pending_target_agent");
|
|
274
|
+
agent.remove("pending_grace_secs");
|
|
275
|
+
agent.insert("capture_state".to_string(), serde_json::json!("captured"));
|
|
276
|
+
true
|
|
277
|
+
}
|
|
278
|
+
|
|
175
279
|
pub(super) fn verify_fork_registration(
|
|
176
280
|
workspace: &Path,
|
|
177
281
|
team_key: &str,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
#[allow(clippy::too_many_arguments)]
|
|
4
|
+
pub(crate) fn upsert_pending_forked_agent_state(
|
|
5
|
+
state: &mut serde_json::Value,
|
|
6
|
+
source_agent_id: &AgentId,
|
|
7
|
+
as_agent_id: &AgentId,
|
|
8
|
+
spec_agent: &Value,
|
|
9
|
+
safety: &DangerousApproval,
|
|
10
|
+
plan: &crate::provider::CommandPlan,
|
|
11
|
+
profile_launch: &crate::provider::ProviderProfileLaunch,
|
|
12
|
+
spawn: &crate::transport::SpawnResult,
|
|
13
|
+
profile_dir: Option<&Path>,
|
|
14
|
+
dynamic_role_file: &Path,
|
|
15
|
+
pending: &crate::provider::session::PendingContextFork,
|
|
16
|
+
spawn_epoch: u64,
|
|
17
|
+
) -> Result<(), LifecycleError> {
|
|
18
|
+
let root = state.as_object_mut().ok_or_else(|| {
|
|
19
|
+
LifecycleError::StatePersist("runtime state root is not an object".to_string())
|
|
20
|
+
})?;
|
|
21
|
+
let agents = root
|
|
22
|
+
.entry("agents".to_string())
|
|
23
|
+
.or_insert_with(|| serde_json::json!({}))
|
|
24
|
+
.as_object_mut()
|
|
25
|
+
.ok_or_else(|| {
|
|
26
|
+
LifecycleError::StatePersist("runtime state agents is not an object".to_string())
|
|
27
|
+
})?;
|
|
28
|
+
let mut entry = serde_json::Map::new();
|
|
29
|
+
entry.insert("status".to_string(), serde_json::json!("running"));
|
|
30
|
+
entry.insert(
|
|
31
|
+
"capture_state".to_string(),
|
|
32
|
+
serde_json::json!("pending_context_fork"),
|
|
33
|
+
);
|
|
34
|
+
entry.insert(
|
|
35
|
+
"agent_id".to_string(),
|
|
36
|
+
serde_json::json!(as_agent_id.as_str()),
|
|
37
|
+
);
|
|
38
|
+
entry.insert(
|
|
39
|
+
"window".to_string(),
|
|
40
|
+
serde_json::json!(as_agent_id.as_str()),
|
|
41
|
+
);
|
|
42
|
+
entry.insert(
|
|
43
|
+
"forked_from".to_string(),
|
|
44
|
+
serde_json::json!(source_agent_id.as_str()),
|
|
45
|
+
);
|
|
46
|
+
entry.insert(
|
|
47
|
+
"fork_source_session_id".to_string(),
|
|
48
|
+
serde_json::json!(pending.source_session_id.as_str()),
|
|
49
|
+
);
|
|
50
|
+
entry.insert(
|
|
51
|
+
"pending_target_agent".to_string(),
|
|
52
|
+
serde_json::json!(pending.target_agent),
|
|
53
|
+
);
|
|
54
|
+
entry.insert(
|
|
55
|
+
"dynamic_role_file".to_string(),
|
|
56
|
+
serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
|
|
57
|
+
);
|
|
58
|
+
entry.insert(
|
|
59
|
+
"role_source_ownership".to_string(),
|
|
60
|
+
serde_json::json!("managed"),
|
|
61
|
+
);
|
|
62
|
+
entry.insert(
|
|
63
|
+
"spawn_cwd".to_string(),
|
|
64
|
+
serde_json::json!(pending
|
|
65
|
+
.scanner_context
|
|
66
|
+
.spawn_cwd
|
|
67
|
+
.to_string_lossy()
|
|
68
|
+
.to_string()),
|
|
69
|
+
);
|
|
70
|
+
entry.insert(
|
|
71
|
+
"pane_id".to_string(),
|
|
72
|
+
serde_json::json!(spawn.pane_id.as_str()),
|
|
73
|
+
);
|
|
74
|
+
entry.insert(
|
|
75
|
+
"spawned_at".to_string(),
|
|
76
|
+
serde_json::json!(pending.spawned_at),
|
|
77
|
+
);
|
|
78
|
+
entry.insert("spawn_epoch".to_string(), serde_json::json!(spawn_epoch));
|
|
79
|
+
if let Some(pid) = spawn.child_pid {
|
|
80
|
+
entry.insert("pane_pid".to_string(), serde_json::json!(pid));
|
|
81
|
+
}
|
|
82
|
+
for key in [
|
|
83
|
+
"provider",
|
|
84
|
+
"auth_mode",
|
|
85
|
+
"model",
|
|
86
|
+
"profile",
|
|
87
|
+
"role",
|
|
88
|
+
"effort",
|
|
89
|
+
] {
|
|
90
|
+
if let Some(value) = spec_agent.get(key) {
|
|
91
|
+
entry.insert(key.to_string(), yaml_value_to_json(value));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if spec_agent.get("profile").is_some() {
|
|
95
|
+
if let Some(profile_dir) = profile_dir {
|
|
96
|
+
entry.insert(
|
|
97
|
+
"_profile_dir".to_string(),
|
|
98
|
+
serde_json::json!(profile_dir.to_string_lossy().to_string()),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
persist_command_plan_state(&mut entry, plan, profile_launch);
|
|
103
|
+
persist_effective_approval_policy(&mut entry, safety);
|
|
104
|
+
agents.insert(
|
|
105
|
+
as_agent_id.as_str().to_string(),
|
|
106
|
+
serde_json::Value::Object(entry),
|
|
107
|
+
);
|
|
108
|
+
Ok(())
|
|
109
|
+
}
|
|
@@ -14,6 +14,10 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
|
|
|
14
14
|
|
|
15
15
|
use super::*;
|
|
16
16
|
|
|
17
|
+
#[path = "fork_pending.rs"]
|
|
18
|
+
mod fork_pending;
|
|
19
|
+
pub(super) use fork_pending::upsert_pending_forked_agent_state;
|
|
20
|
+
|
|
17
21
|
pub(super) fn reserve_forked_agent_state(
|
|
18
22
|
state: &mut serde_json::Value,
|
|
19
23
|
source_agent_id: &AgentId,
|
|
@@ -134,6 +138,51 @@ pub(super) fn find_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option
|
|
|
134
138
|
})
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
pub(super) fn fork_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option<&'a Value> {
|
|
142
|
+
find_spec_agent(spec, agent_id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
pub(super) fn fork_source_tuple(
|
|
146
|
+
state: &serde_json::Value,
|
|
147
|
+
source_agent_id: &AgentId,
|
|
148
|
+
) -> Result<(crate::provider::SessionId, PathBuf), LifecycleError> {
|
|
149
|
+
let source = state
|
|
150
|
+
.get("agents")
|
|
151
|
+
.and_then(|agents| agents.get(source_agent_id.as_str()))
|
|
152
|
+
.ok_or_else(|| {
|
|
153
|
+
LifecycleError::Provider(format!("unknown worker agent id: {source_agent_id}"))
|
|
154
|
+
})?;
|
|
155
|
+
let field = |name: &str| {
|
|
156
|
+
source
|
|
157
|
+
.get(name)
|
|
158
|
+
.and_then(serde_json::Value::as_str)
|
|
159
|
+
.filter(|value| !value.is_empty())
|
|
160
|
+
};
|
|
161
|
+
let (Some(session_id), Some(backing)) = (field("session_id"), field("rollout_path")) else {
|
|
162
|
+
return Err(LifecycleError::Provider(format!(
|
|
163
|
+
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
164
|
+
(session_id+rollout_path+captured_at+captured_via required)"
|
|
165
|
+
)));
|
|
166
|
+
};
|
|
167
|
+
if field("captured_at").is_none() || field("captured_via").is_none() {
|
|
168
|
+
return Err(LifecycleError::Provider(format!(
|
|
169
|
+
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
170
|
+
(session_id+rollout_path+captured_at+captured_via required)"
|
|
171
|
+
)));
|
|
172
|
+
}
|
|
173
|
+
let backing = PathBuf::from(backing);
|
|
174
|
+
if !backing.is_file() {
|
|
175
|
+
return Err(LifecycleError::Provider(format!(
|
|
176
|
+
"cannot fork {source_agent_id}: source session backing is not readable: {}",
|
|
177
|
+
backing.display()
|
|
178
|
+
)));
|
|
179
|
+
}
|
|
180
|
+
Ok((
|
|
181
|
+
crate::provider::SessionId::new(session_id.to_string()),
|
|
182
|
+
backing,
|
|
183
|
+
))
|
|
184
|
+
}
|
|
185
|
+
|
|
137
186
|
pub(super) fn append_forked_agent(
|
|
138
187
|
spec: &Value,
|
|
139
188
|
source_agent: &Value,
|
|
@@ -664,6 +664,14 @@ pub struct ForkAgentReport {
|
|
|
664
664
|
pub new_agent_id: AgentId,
|
|
665
665
|
pub env: AgentActionEnvelope,
|
|
666
666
|
pub session_id: Option<SessionId>,
|
|
667
|
+
pub backing_state: ForkBackingState,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
671
|
+
#[serde(rename_all = "snake_case")]
|
|
672
|
+
pub enum ForkBackingState {
|
|
673
|
+
Verified,
|
|
674
|
+
PendingContextFork,
|
|
667
675
|
}
|
|
668
676
|
|
|
669
677
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -130,6 +130,7 @@ pub(crate) fn fork_agent(
|
|
|
130
130
|
"state_file": report.env.state_file.to_string_lossy().to_string(),
|
|
131
131
|
"coordinator_started": report.env.coordinator_started,
|
|
132
132
|
"session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
133
|
+
"backing_state": report.backing_state,
|
|
133
134
|
})),
|
|
134
135
|
})
|
|
135
136
|
}
|
|
@@ -469,13 +469,25 @@ fn classify_pending_capture_state(
|
|
|
469
469
|
const TRIGGER_GRACE_SECS: i64 = 15;
|
|
470
470
|
let past_spawn_grace = elapsed_since_spawn >= SPAWN_GRACE_SECS;
|
|
471
471
|
let past_trigger_grace = has_trigger && elapsed_since_trigger >= TRIGGER_GRACE_SECS;
|
|
472
|
+
let fork_pending_expired = agent_obj.get("capture_state").and_then(Value::as_str)
|
|
473
|
+
== Some("pending_context_fork")
|
|
474
|
+
&& crate::provider::session::transition_pending_context_fork(
|
|
475
|
+
has_trigger,
|
|
476
|
+
past_spawn_grace && past_trigger_grace,
|
|
477
|
+
)
|
|
478
|
+
.is_some();
|
|
472
479
|
// transcript_missing requires BOTH: a trigger occurred AND we've
|
|
473
480
|
// waited past the spawn grace (so we're not blamed for slow startup).
|
|
474
481
|
// candidate_count == 0 is the "no backing" signal; non-zero
|
|
475
482
|
// candidates that didn't satisfy assignment go through the ambiguous
|
|
476
483
|
// path above and don't reach here.
|
|
477
|
-
if has_trigger && past_spawn_grace && past_trigger_grace
|
|
484
|
+
if (fork_pending_expired || has_trigger && past_spawn_grace && past_trigger_grace)
|
|
485
|
+
&& candidate_count == 0
|
|
486
|
+
{
|
|
478
487
|
"transcript_missing"
|
|
488
|
+
} else if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork")
|
|
489
|
+
{
|
|
490
|
+
"pending_context_fork"
|
|
479
491
|
} else {
|
|
480
492
|
"pending_first_turn"
|
|
481
493
|
}
|
|
@@ -1143,6 +1155,9 @@ fn apply_captured_session(
|
|
|
1143
1155
|
agent_obj: &mut serde_json::Map<String, Value>,
|
|
1144
1156
|
captured: &CapturedSession,
|
|
1145
1157
|
) -> bool {
|
|
1158
|
+
if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork") {
|
|
1159
|
+
return crate::lifecycle::finalize_pending_fork_capture(agent_obj, captured);
|
|
1160
|
+
}
|
|
1146
1161
|
let Some(session_id) = captured.session_id.as_ref() else {
|
|
1147
1162
|
return false;
|
|
1148
1163
|
};
|
|
@@ -1984,6 +1999,27 @@ mod u1_tests {
|
|
|
1984
1999
|
);
|
|
1985
2000
|
}
|
|
1986
2001
|
|
|
2002
|
+
#[test]
|
|
2003
|
+
fn pending_context_fork_keeps_typed_state_until_trigger_grace_expires() {
|
|
2004
|
+
let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
|
|
2005
|
+
let mut agent = serde_json::Map::new();
|
|
2006
|
+
agent.insert("spawned_at".to_string(), serde_json::json!(old));
|
|
2007
|
+
agent.insert(
|
|
2008
|
+
"capture_state".to_string(),
|
|
2009
|
+
serde_json::json!("pending_context_fork"),
|
|
2010
|
+
);
|
|
2011
|
+
assert_eq!(
|
|
2012
|
+
super::classify_pending_capture_state(&agent, 0),
|
|
2013
|
+
"pending_context_fork"
|
|
2014
|
+
);
|
|
2015
|
+
let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
|
|
2016
|
+
agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
|
|
2017
|
+
assert_eq!(
|
|
2018
|
+
super::classify_pending_capture_state(&agent, 0),
|
|
2019
|
+
"transcript_missing"
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
1987
2023
|
#[test]
|
|
1988
2024
|
fn canonical_pending_capture_preserves_legacy_missing_boundary() {
|
|
1989
2025
|
let agent = serde_json::json!({
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn verify_claude_fork(
|
|
4
|
+
provider: Provider,
|
|
5
|
+
source_session_id: &SessionId,
|
|
6
|
+
plan: &CommandPlan,
|
|
7
|
+
before: &ContextBackingSnapshot,
|
|
8
|
+
expected_backing_path: Option<&Path>,
|
|
9
|
+
spawn_cwd: &Path,
|
|
10
|
+
deadline: Duration,
|
|
11
|
+
) -> Result<ContextForkProof, ContextForkTermination> {
|
|
12
|
+
let expected = plan.expected_session_id.as_ref().ok_or_else(|| {
|
|
13
|
+
ProviderError::CaptureFailed(
|
|
14
|
+
"context_fork_unverified: Claude plan has no expected session id".to_string(),
|
|
15
|
+
)
|
|
16
|
+
})?;
|
|
17
|
+
let path = expected_backing_path.ok_or_else(|| {
|
|
18
|
+
ProviderError::CaptureFailed(
|
|
19
|
+
"context_fork_unverified: Claude plan has no exact snapshot backing".to_string(),
|
|
20
|
+
)
|
|
21
|
+
})?;
|
|
22
|
+
let expected_name = format!("{}.jsonl", expected.as_str());
|
|
23
|
+
if path.file_name().and_then(|name| name.to_str()) != Some(expected_name.as_str()) {
|
|
24
|
+
return Err(ProviderError::CaptureFailed(format!(
|
|
25
|
+
"context_fork_unverified: Claude snapshot backing does not match expected session {}",
|
|
26
|
+
expected.as_str()
|
|
27
|
+
))
|
|
28
|
+
.into());
|
|
29
|
+
}
|
|
30
|
+
let started = std::time::Instant::now();
|
|
31
|
+
loop {
|
|
32
|
+
if let Some(stamp) = file_stamp(path) {
|
|
33
|
+
let changed = before.files.get(path).is_none_or(|old| *old != stamp);
|
|
34
|
+
let observed_matches =
|
|
35
|
+
session_id_from_jsonl(path).is_none_or(|observed| observed == expected.as_str());
|
|
36
|
+
if changed && readable_jsonl(path) && observed_matches && expected != source_session_id
|
|
37
|
+
{
|
|
38
|
+
return Ok(ContextForkProof {
|
|
39
|
+
provider,
|
|
40
|
+
source_session_id: source_session_id.clone(),
|
|
41
|
+
new_session_id: expected.clone(),
|
|
42
|
+
backing_path: path.to_path_buf(),
|
|
43
|
+
captured_via: "context_fork_verified".to_string(),
|
|
44
|
+
attribution_confidence: "high".to_string(),
|
|
45
|
+
managed_backing_root: None,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if let Some(provider_path) = std::env::var_os("HOME")
|
|
50
|
+
.map(PathBuf::from)
|
|
51
|
+
.and_then(|home| {
|
|
52
|
+
crate::provider::session_scan::claude::projects_dir_for_cwd(&home, spawn_cwd)
|
|
53
|
+
})
|
|
54
|
+
.map(|root| root.join(&expected_name))
|
|
55
|
+
{
|
|
56
|
+
let observed_matches = session_id_from_jsonl(&provider_path)
|
|
57
|
+
.is_some_and(|observed| observed == expected.as_str());
|
|
58
|
+
let changed = file_stamp(&provider_path)
|
|
59
|
+
.is_some_and(|stamp| before.files.get(&provider_path) != Some(&stamp));
|
|
60
|
+
if changed
|
|
61
|
+
&& readable_jsonl(&provider_path)
|
|
62
|
+
&& observed_matches
|
|
63
|
+
&& expected != source_session_id
|
|
64
|
+
{
|
|
65
|
+
return Ok(ContextForkProof {
|
|
66
|
+
provider,
|
|
67
|
+
source_session_id: source_session_id.clone(),
|
|
68
|
+
new_session_id: expected.clone(),
|
|
69
|
+
backing_path: provider_path,
|
|
70
|
+
captured_via: "context_fork_verified".to_string(),
|
|
71
|
+
attribution_confidence: "high".to_string(),
|
|
72
|
+
managed_backing_root: None,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if started.elapsed() >= deadline {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
80
|
+
}
|
|
81
|
+
Err(ContextForkTermination::Timeout {
|
|
82
|
+
provider,
|
|
83
|
+
deadline_ms: deadline.as_millis(),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn verify_codex_fork(
|
|
4
|
+
source_session_id: &SessionId,
|
|
5
|
+
plan: &CommandPlan,
|
|
6
|
+
before: &ContextBackingSnapshot,
|
|
7
|
+
agent_id: &str,
|
|
8
|
+
spawn_cwd: &Path,
|
|
9
|
+
spawned_at: &str,
|
|
10
|
+
deadline: Duration,
|
|
11
|
+
) -> Result<ContextForkProof, ContextForkTermination> {
|
|
12
|
+
let context = crate::provider::session_scan::CaptureSessionContext {
|
|
13
|
+
agent_id: agent_id.to_string(),
|
|
14
|
+
spawn_cwd: spawn_cwd.to_path_buf(),
|
|
15
|
+
pane_id: None,
|
|
16
|
+
pane_pid: None,
|
|
17
|
+
spawned_at: Some(spawned_at.to_string()),
|
|
18
|
+
expected_session_id: plan.expected_session_id.clone(),
|
|
19
|
+
provider_projects_root: plan.provider_projects_root.clone(),
|
|
20
|
+
};
|
|
21
|
+
let excluded = outcome::source_exclusions(before, source_session_id);
|
|
22
|
+
let started = std::time::Instant::now();
|
|
23
|
+
loop {
|
|
24
|
+
let current = jsonl_files(&before.root);
|
|
25
|
+
for candidate in
|
|
26
|
+
crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
|
|
27
|
+
{
|
|
28
|
+
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
29
|
+
continue;
|
|
30
|
+
};
|
|
31
|
+
let Some(stamp) = current.get(path.as_path()) else {
|
|
32
|
+
continue;
|
|
33
|
+
};
|
|
34
|
+
let snapshot_changed = before.files.get(path.as_path()) != Some(stamp);
|
|
35
|
+
if !snapshot_changed {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let Some(new_session_id) = candidate.captured.session_id else {
|
|
39
|
+
continue;
|
|
40
|
+
};
|
|
41
|
+
if excluded.contains(new_session_id.as_str())
|
|
42
|
+
|| excluded.contains(&path.as_path().to_string_lossy().to_string())
|
|
43
|
+
{
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
return Ok(ContextForkProof {
|
|
47
|
+
provider: Provider::Codex,
|
|
48
|
+
source_session_id: source_session_id.clone(),
|
|
49
|
+
new_session_id,
|
|
50
|
+
backing_path: path.as_path().to_path_buf(),
|
|
51
|
+
captured_via: "context_fork_verified".to_string(),
|
|
52
|
+
attribution_confidence: "high".to_string(),
|
|
53
|
+
managed_backing_root: None,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if started.elapsed() >= deadline {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
60
|
+
}
|
|
61
|
+
Err(ContextForkTermination::Timeout {
|
|
62
|
+
provider: Provider::Codex,
|
|
63
|
+
deadline_ms: deadline.as_millis(),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
use std::collections::BTreeSet;
|
|
3
|
+
|
|
4
|
+
pub(super) fn source_exclusions(
|
|
5
|
+
before: &ContextBackingSnapshot,
|
|
6
|
+
source_session_id: &SessionId,
|
|
7
|
+
) -> BTreeSet<String> {
|
|
8
|
+
let mut excluded = BTreeSet::from([source_session_id.as_str().to_string()]);
|
|
9
|
+
for path in before.files.keys() {
|
|
10
|
+
if session_id_from_jsonl(path).as_deref() == Some(source_session_id.as_str()) {
|
|
11
|
+
excluded.insert(path.to_string_lossy().to_string());
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
excluded
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Clone)]
|
|
18
|
+
pub(crate) struct PendingContextFork {
|
|
19
|
+
pub source_session_id: SessionId,
|
|
20
|
+
pub target_agent: String,
|
|
21
|
+
pub spawned_at: String,
|
|
22
|
+
pub scanner_context: crate::provider::session_scan::CaptureSessionContext,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug)]
|
|
26
|
+
pub(crate) enum ContextForkOutcome {
|
|
27
|
+
Verified(ContextForkProof),
|
|
28
|
+
Pending(PendingContextFork),
|
|
29
|
+
Rejected(ProviderError),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
33
|
+
pub(crate) enum ContextForkPendingFailure {
|
|
34
|
+
TranscriptMissing,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub(crate) fn transition_pending_context_fork(
|
|
38
|
+
triggered: bool,
|
|
39
|
+
grace_expired: bool,
|
|
40
|
+
) -> Option<ContextForkPendingFailure> {
|
|
41
|
+
(triggered && grace_expired).then_some(ContextForkPendingFailure::TranscriptMissing)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub(crate) fn observe_context_fork(
|
|
45
|
+
provider: Provider,
|
|
46
|
+
source_session_id: &SessionId,
|
|
47
|
+
plan: &CommandPlan,
|
|
48
|
+
before: &ContextBackingSnapshot,
|
|
49
|
+
expected_backing_path: Option<&Path>,
|
|
50
|
+
agent_id: &str,
|
|
51
|
+
spawn_cwd: &Path,
|
|
52
|
+
spawned_at: &str,
|
|
53
|
+
deadline: Duration,
|
|
54
|
+
) -> ContextForkOutcome {
|
|
55
|
+
match verify_context_fork(
|
|
56
|
+
provider,
|
|
57
|
+
source_session_id,
|
|
58
|
+
plan,
|
|
59
|
+
before,
|
|
60
|
+
expected_backing_path,
|
|
61
|
+
agent_id,
|
|
62
|
+
spawn_cwd,
|
|
63
|
+
spawned_at,
|
|
64
|
+
deadline,
|
|
65
|
+
) {
|
|
66
|
+
Ok(proof) => ContextForkOutcome::Verified(proof),
|
|
67
|
+
Err(ContextForkTermination::Timeout { .. }) => {
|
|
68
|
+
ContextForkOutcome::Pending(PendingContextFork {
|
|
69
|
+
source_session_id: source_session_id.clone(),
|
|
70
|
+
target_agent: agent_id.to_string(),
|
|
71
|
+
spawned_at: spawned_at.to_string(),
|
|
72
|
+
scanner_context: crate::provider::session_scan::CaptureSessionContext {
|
|
73
|
+
agent_id: agent_id.to_string(),
|
|
74
|
+
spawn_cwd: spawn_cwd.to_path_buf(),
|
|
75
|
+
pane_id: None,
|
|
76
|
+
pane_pid: None,
|
|
77
|
+
spawned_at: Some(spawned_at.to_string()),
|
|
78
|
+
expected_session_id: plan.expected_session_id.clone(),
|
|
79
|
+
provider_projects_root: plan.provider_projects_root.clone(),
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
Err(ContextForkTermination::Rejected(error)) => ContextForkOutcome::Rejected(error),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#[cfg(test)]
|
|
88
|
+
mod tests {
|
|
89
|
+
use super::*;
|
|
90
|
+
|
|
91
|
+
#[test]
|
|
92
|
+
fn convergence_deadlines_are_provider_specific() {
|
|
93
|
+
assert_eq!(
|
|
94
|
+
context_fork_convergence_deadline(Provider::Claude),
|
|
95
|
+
Duration::from_secs(45)
|
|
96
|
+
);
|
|
97
|
+
assert_eq!(
|
|
98
|
+
context_fork_convergence_deadline(Provider::Codex),
|
|
99
|
+
Duration::from_secs(10)
|
|
100
|
+
);
|
|
101
|
+
assert_eq!(
|
|
102
|
+
context_fork_convergence_deadline(Provider::Copilot),
|
|
103
|
+
Duration::from_secs(5)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[test]
|
|
108
|
+
fn codex_without_new_backing_is_typed_pending() {
|
|
109
|
+
let root =
|
|
110
|
+
std::env::temp_dir().join(format!("ta-codex-fork-pending-{}", std::process::id()));
|
|
111
|
+
let _ = std::fs::remove_dir_all(&root);
|
|
112
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
113
|
+
let plan = CommandPlan {
|
|
114
|
+
argv: Vec::new(),
|
|
115
|
+
expected_session_id: None,
|
|
116
|
+
provider_projects_root: Some(root.clone()),
|
|
117
|
+
managed_mcp_config: false,
|
|
118
|
+
};
|
|
119
|
+
let before = ContextBackingSnapshot::capture(Provider::Codex, &plan);
|
|
120
|
+
let outcome = observe_context_fork(
|
|
121
|
+
Provider::Codex,
|
|
122
|
+
&SessionId::new("source-session"),
|
|
123
|
+
&plan,
|
|
124
|
+
&before,
|
|
125
|
+
None,
|
|
126
|
+
"fork",
|
|
127
|
+
&root,
|
|
128
|
+
"2026-07-24T00:00:00Z",
|
|
129
|
+
Duration::ZERO,
|
|
130
|
+
);
|
|
131
|
+
let ContextForkOutcome::Pending(pending) = outcome else {
|
|
132
|
+
panic!("missing backing must remain a typed pending fork")
|
|
133
|
+
};
|
|
134
|
+
assert_eq!(pending.source_session_id.as_str(), "source-session");
|
|
135
|
+
assert_eq!(pending.target_agent, "fork");
|
|
136
|
+
let _ = std::fs::remove_dir_all(&root);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -5,7 +5,28 @@ use std::time::{Duration, SystemTime};
|
|
|
5
5
|
use crate::model::enums::Provider;
|
|
6
6
|
use crate::provider::{CommandPlan, ProviderError, SessionId};
|
|
7
7
|
|
|
8
|
+
#[derive(Debug, thiserror::Error)]
|
|
9
|
+
pub(crate) enum ContextForkTermination {
|
|
10
|
+
#[error(
|
|
11
|
+
"context_fork_unverified: {provider:?} produced no readable NEW session backing within {deadline_ms}ms"
|
|
12
|
+
)]
|
|
13
|
+
Timeout {
|
|
14
|
+
provider: Provider,
|
|
15
|
+
deadline_ms: u128,
|
|
16
|
+
},
|
|
17
|
+
#[error(transparent)]
|
|
18
|
+
Rejected(#[from] ProviderError),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
mod claude;
|
|
22
|
+
mod codex;
|
|
23
|
+
mod outcome;
|
|
24
|
+
pub(crate) use outcome::{
|
|
25
|
+
observe_context_fork, transition_pending_context_fork, ContextForkOutcome, PendingContextFork,
|
|
26
|
+
};
|
|
27
|
+
|
|
8
28
|
pub(crate) fn context_fork_convergence_deadline(provider: Provider) -> Duration {
|
|
29
|
+
// Expiration is consumed by ContextForkOutcome::Pending(PendingContextFork).
|
|
9
30
|
match provider {
|
|
10
31
|
Provider::Claude | Provider::ClaudeCode => Duration::from_secs(45),
|
|
11
32
|
Provider::Codex => Duration::from_secs(10),
|
|
@@ -54,12 +75,12 @@ pub(crate) fn verify_context_fork(
|
|
|
54
75
|
spawn_cwd: &Path,
|
|
55
76
|
spawned_at: &str,
|
|
56
77
|
deadline: Duration,
|
|
57
|
-
) -> Result<ContextForkProof,
|
|
78
|
+
) -> Result<ContextForkProof, ContextForkTermination> {
|
|
58
79
|
if provider == Provider::Copilot {
|
|
59
|
-
return verify_copilot_fork(source_session_id, plan);
|
|
80
|
+
return verify_copilot_fork(source_session_id, plan).map_err(Into::into);
|
|
60
81
|
}
|
|
61
82
|
if provider == Provider::Codex {
|
|
62
|
-
return verify_codex_fork(
|
|
83
|
+
return codex::verify_codex_fork(
|
|
63
84
|
source_session_id,
|
|
64
85
|
plan,
|
|
65
86
|
before,
|
|
@@ -70,132 +91,20 @@ pub(crate) fn verify_context_fork(
|
|
|
70
91
|
);
|
|
71
92
|
}
|
|
72
93
|
if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
73
|
-
return verify_claude_fork(
|
|
94
|
+
return claude::verify_claude_fork(
|
|
74
95
|
provider,
|
|
75
96
|
source_session_id,
|
|
76
97
|
plan,
|
|
77
98
|
before,
|
|
78
99
|
expected_backing_path,
|
|
100
|
+
spawn_cwd,
|
|
79
101
|
deadline,
|
|
80
102
|
);
|
|
81
103
|
}
|
|
82
104
|
Err(ProviderError::CaptureFailed(format!(
|
|
83
105
|
"context_fork_unverified: {provider:?} has no verifiable fork backing"
|
|
84
|
-
))
|
|
85
|
-
|
|
86
|
-
fn verify_claude_fork(
|
|
87
|
-
provider: Provider,
|
|
88
|
-
source_session_id: &SessionId,
|
|
89
|
-
plan: &CommandPlan,
|
|
90
|
-
before: &ContextBackingSnapshot,
|
|
91
|
-
expected_backing_path: Option<&Path>,
|
|
92
|
-
deadline: Duration,
|
|
93
|
-
) -> Result<ContextForkProof, ProviderError> {
|
|
94
|
-
let expected = plan.expected_session_id.as_ref().ok_or_else(|| {
|
|
95
|
-
ProviderError::CaptureFailed(
|
|
96
|
-
"context_fork_unverified: Claude plan has no expected session id".to_string(),
|
|
97
|
-
)
|
|
98
|
-
})?;
|
|
99
|
-
let path = expected_backing_path.ok_or_else(|| {
|
|
100
|
-
ProviderError::CaptureFailed(
|
|
101
|
-
"context_fork_unverified: Claude plan has no exact snapshot backing".to_string(),
|
|
102
|
-
)
|
|
103
|
-
})?;
|
|
104
|
-
let expected_name = format!("{}.jsonl", expected.as_str());
|
|
105
|
-
if path.file_name().and_then(|name| name.to_str()) != Some(expected_name.as_str()) {
|
|
106
|
-
return Err(ProviderError::CaptureFailed(format!(
|
|
107
|
-
"context_fork_unverified: Claude snapshot backing does not match expected session {}",
|
|
108
|
-
expected.as_str()
|
|
109
|
-
)));
|
|
110
|
-
}
|
|
111
|
-
let started = std::time::Instant::now();
|
|
112
|
-
loop {
|
|
113
|
-
if let Some(stamp) = file_stamp(path) {
|
|
114
|
-
let changed = before.files.get(path).is_none_or(|old| *old != stamp);
|
|
115
|
-
let observed_matches =
|
|
116
|
-
session_id_from_jsonl(path).is_none_or(|observed| observed == expected.as_str());
|
|
117
|
-
if changed && readable_jsonl(path) && observed_matches && expected != source_session_id
|
|
118
|
-
{
|
|
119
|
-
return Ok(ContextForkProof {
|
|
120
|
-
provider,
|
|
121
|
-
source_session_id: source_session_id.clone(),
|
|
122
|
-
new_session_id: expected.clone(),
|
|
123
|
-
backing_path: path.to_path_buf(),
|
|
124
|
-
captured_via: "context_fork_verified".to_string(),
|
|
125
|
-
attribution_confidence: "high".to_string(),
|
|
126
|
-
managed_backing_root: None,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if started.elapsed() >= deadline {
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
std::thread::sleep(Duration::from_millis(50));
|
|
134
|
-
}
|
|
135
|
-
Err(ProviderError::CaptureFailed(format!(
|
|
136
|
-
"context_fork_unverified: {provider:?} produced no readable NEW session backing within {}ms",
|
|
137
|
-
deadline.as_millis()
|
|
138
|
-
)))
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
fn verify_codex_fork(
|
|
142
|
-
source_session_id: &SessionId,
|
|
143
|
-
plan: &CommandPlan,
|
|
144
|
-
before: &ContextBackingSnapshot,
|
|
145
|
-
agent_id: &str,
|
|
146
|
-
spawn_cwd: &Path,
|
|
147
|
-
spawned_at: &str,
|
|
148
|
-
deadline: Duration,
|
|
149
|
-
) -> Result<ContextForkProof, ProviderError> {
|
|
150
|
-
let context = crate::provider::session_scan::CaptureSessionContext {
|
|
151
|
-
agent_id: agent_id.to_string(),
|
|
152
|
-
spawn_cwd: spawn_cwd.to_path_buf(),
|
|
153
|
-
pane_id: None,
|
|
154
|
-
pane_pid: None,
|
|
155
|
-
spawned_at: Some(spawned_at.to_string()),
|
|
156
|
-
expected_session_id: plan.expected_session_id.clone(),
|
|
157
|
-
provider_projects_root: plan.provider_projects_root.clone(),
|
|
158
|
-
};
|
|
159
|
-
let started = std::time::Instant::now();
|
|
160
|
-
loop {
|
|
161
|
-
let current = jsonl_files(&before.root);
|
|
162
|
-
for candidate in
|
|
163
|
-
crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
|
|
164
|
-
{
|
|
165
|
-
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
166
|
-
continue;
|
|
167
|
-
};
|
|
168
|
-
let Some(stamp) = current.get(path.as_path()) else {
|
|
169
|
-
continue;
|
|
170
|
-
};
|
|
171
|
-
if before.files.get(path.as_path()) == Some(stamp) {
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
let Some(new_session_id) = candidate.captured.session_id else {
|
|
175
|
-
continue;
|
|
176
|
-
};
|
|
177
|
-
if new_session_id == *source_session_id {
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
return Ok(ContextForkProof {
|
|
181
|
-
provider: Provider::Codex,
|
|
182
|
-
source_session_id: source_session_id.clone(),
|
|
183
|
-
new_session_id,
|
|
184
|
-
backing_path: path.as_path().to_path_buf(),
|
|
185
|
-
captured_via: "context_fork_verified".to_string(),
|
|
186
|
-
attribution_confidence: "high".to_string(),
|
|
187
|
-
managed_backing_root: None,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
if started.elapsed() >= deadline {
|
|
191
|
-
break;
|
|
192
|
-
}
|
|
193
|
-
std::thread::sleep(Duration::from_millis(50));
|
|
194
|
-
}
|
|
195
|
-
Err(ProviderError::CaptureFailed(format!(
|
|
196
|
-
"context_fork_unverified: Codex produced no readable NEW session backing within {}ms",
|
|
197
|
-
deadline.as_millis()
|
|
198
|
-
)))
|
|
106
|
+
))
|
|
107
|
+
.into())
|
|
199
108
|
}
|
|
200
109
|
|
|
201
110
|
fn verify_copilot_fork(
|
|
@@ -242,6 +151,8 @@ fn verify_copilot_fork(
|
|
|
242
151
|
"context_fork_unverified: copilot NEW session is absent from session-store".to_string(),
|
|
243
152
|
));
|
|
244
153
|
}
|
|
154
|
+
// Copilot's isolated database row is its synchronous fork proof; the
|
|
155
|
+
// pre-spawn materialization baseline is never accepted by transcript scan.
|
|
245
156
|
Ok(ContextForkProof {
|
|
246
157
|
provider: Provider::Copilot,
|
|
247
158
|
source_session_id: source_session_id.clone(),
|
|
@@ -335,22 +246,6 @@ mod tests {
|
|
|
335
246
|
use super::*;
|
|
336
247
|
use std::io::Write;
|
|
337
248
|
|
|
338
|
-
#[test]
|
|
339
|
-
fn context_fork_deadlines_are_provider_specific() {
|
|
340
|
-
assert_eq!(
|
|
341
|
-
context_fork_convergence_deadline(Provider::Claude),
|
|
342
|
-
Duration::from_secs(45)
|
|
343
|
-
);
|
|
344
|
-
assert_eq!(
|
|
345
|
-
context_fork_convergence_deadline(Provider::Codex),
|
|
346
|
-
Duration::from_secs(10)
|
|
347
|
-
);
|
|
348
|
-
assert_eq!(
|
|
349
|
-
context_fork_convergence_deadline(Provider::Copilot),
|
|
350
|
-
Duration::from_secs(5)
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
249
|
#[test]
|
|
355
250
|
fn codex_fork_proof_ignores_changed_stale_and_foreign_rollouts() {
|
|
356
251
|
let root =
|
|
@@ -10,7 +10,8 @@ pub mod resume;
|
|
|
10
10
|
|
|
11
11
|
pub use context_fork::ContextForkProof;
|
|
12
12
|
pub(crate) use context_fork::{
|
|
13
|
-
context_fork_convergence_deadline,
|
|
13
|
+
context_fork_convergence_deadline, observe_context_fork, transition_pending_context_fork,
|
|
14
|
+
ContextBackingSnapshot, ContextForkOutcome, PendingContextFork,
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
pub use resume::{
|
|
@@ -5,7 +5,7 @@ use crate::provider::Provider;
|
|
|
5
5
|
|
|
6
6
|
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
7
7
|
|
|
8
|
-
pub(
|
|
8
|
+
pub(crate) fn projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
|
|
9
9
|
let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
|
|
10
10
|
let encoded = encode_projects_dir(&canonical.to_string_lossy());
|
|
11
11
|
if encoded.is_empty() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.58",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.58",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.58",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.58"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|