@team-agent/installer 0.5.57 → 0.5.59
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/emit.rs +30 -2
- package/crates/team-agent/src/cli/mod.rs +1 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +68 -25
- package/crates/team-agent/src/cli/spec.rs +1 -1
- 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/mcp_server/normalize.rs +53 -1
- package/crates/team-agent/src/mcp_server/tools.rs +8 -1
- 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/skills/team-agent/SKILL.md +1 -0
|
@@ -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
|
}
|
|
@@ -336,9 +336,20 @@ pub(crate) fn normalize_tests(value: Option<&Value>) -> Vec<NormalizedTest> {
|
|
|
336
336
|
.or_else(|| obj.get("name"))
|
|
337
337
|
.or_else(|| obj.get("test"))
|
|
338
338
|
.and_then(text_of_value)?;
|
|
339
|
+
let status =
|
|
340
|
+
match normalize_token(obj.get("status").and_then(Value::as_str)).as_str() {
|
|
341
|
+
"executed" => {
|
|
342
|
+
if obj.get("exit_code").and_then(Value::as_i64) == Some(0) {
|
|
343
|
+
TestStatus::Passed
|
|
344
|
+
} else {
|
|
345
|
+
TestStatus::Failed
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
_ => normalize_test_status(obj.get("status").and_then(Value::as_str)),
|
|
349
|
+
};
|
|
339
350
|
Some(NormalizedTest {
|
|
340
351
|
command,
|
|
341
|
-
status
|
|
352
|
+
status,
|
|
342
353
|
detail: obj
|
|
343
354
|
.get("detail")
|
|
344
355
|
.or_else(|| obj.get("output"))
|
|
@@ -346,6 +357,7 @@ pub(crate) fn normalize_tests(value: Option<&Value>) -> Vec<NormalizedTest> {
|
|
|
346
357
|
.or_else(|| obj.get("stderr"))
|
|
347
358
|
.or_else(|| obj.get("summary"))
|
|
348
359
|
.or_else(|| obj.get("message"))
|
|
360
|
+
.or_else(|| obj.get("log_path"))
|
|
349
361
|
.and_then(text_of_value),
|
|
350
362
|
})
|
|
351
363
|
}
|
|
@@ -358,6 +370,46 @@ pub(crate) fn normalize_tests(value: Option<&Value>) -> Vec<NormalizedTest> {
|
|
|
358
370
|
.collect()
|
|
359
371
|
}
|
|
360
372
|
|
|
373
|
+
pub(crate) fn validate_test_evidence_schema(value: Option<&Value>) -> Result<(), String> {
|
|
374
|
+
const ALLOWED: &str = r#"allowed schema: {"status":"executed","command":string,"exit_code":integer,"log_path":string}"#;
|
|
375
|
+
for (index, item) in items_from_value(value).iter().enumerate() {
|
|
376
|
+
let Value::Object(obj) = item else {
|
|
377
|
+
continue;
|
|
378
|
+
};
|
|
379
|
+
let status = normalize_token(obj.get("status").and_then(Value::as_str));
|
|
380
|
+
if status == "executed" {
|
|
381
|
+
let valid = obj.get("command").and_then(Value::as_str).is_some()
|
|
382
|
+
&& obj.get("exit_code").and_then(Value::as_i64).is_some()
|
|
383
|
+
&& obj.get("log_path").and_then(Value::as_str).is_some();
|
|
384
|
+
if !valid {
|
|
385
|
+
return Err(format!(
|
|
386
|
+
"unsupported_test_evidence_schema at tests[{index}]; {ALLOWED}"
|
|
387
|
+
));
|
|
388
|
+
}
|
|
389
|
+
} else if !status.is_empty()
|
|
390
|
+
&& !matches!(
|
|
391
|
+
status.as_str(),
|
|
392
|
+
"passed"
|
|
393
|
+
| "pass"
|
|
394
|
+
| "ok"
|
|
395
|
+
| "success"
|
|
396
|
+
| "failed"
|
|
397
|
+
| "fail"
|
|
398
|
+
| "error"
|
|
399
|
+
| "skipped"
|
|
400
|
+
| "skip"
|
|
401
|
+
| "not_run"
|
|
402
|
+
| "notrun"
|
|
403
|
+
)
|
|
404
|
+
{
|
|
405
|
+
return Err(format!(
|
|
406
|
+
"unsupported_test_evidence_schema at tests[{index}]; {ALLOWED}"
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
Ok(())
|
|
411
|
+
}
|
|
412
|
+
|
|
361
413
|
pub(crate) fn normalize_risks(value: Option<&Value>) -> Vec<NormalizedRisk> {
|
|
362
414
|
items_from_value(value)
|
|
363
415
|
.iter()
|
|
@@ -27,7 +27,7 @@ use super::helpers::{
|
|
|
27
27
|
};
|
|
28
28
|
use super::normalize::{
|
|
29
29
|
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
30
|
-
report_result_integrity_warnings,
|
|
30
|
+
report_result_integrity_warnings, validate_test_evidence_schema,
|
|
31
31
|
};
|
|
32
32
|
use super::types::{
|
|
33
33
|
Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers,
|
|
@@ -522,6 +522,13 @@ impl TeamOrchestratorTools {
|
|
|
522
522
|
{
|
|
523
523
|
self.note_unknown_result_status(&raw);
|
|
524
524
|
}
|
|
525
|
+
if let Err(error) = validate_test_evidence_schema(base.get("tests")) {
|
|
526
|
+
return Err(ToolError::new(
|
|
527
|
+
ToolErrorReason::InvalidToolArguments,
|
|
528
|
+
error,
|
|
529
|
+
"UnsupportedTestEvidenceSchema",
|
|
530
|
+
));
|
|
531
|
+
}
|
|
525
532
|
let normalized = normalize_report_envelope(&base);
|
|
526
533
|
if let Some(error) = normalized.presentation_error.as_deref() {
|
|
527
534
|
return Err(ToolError::new(
|
|
@@ -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
|
+
}
|