@team-agent/installer 0.5.59 → 0.5.60
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/coordinator/tick.rs +21 -8
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +56 -50
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +148 -8
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +35 -1
- package/crates/team-agent/src/lifecycle/types.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +57 -0
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +10 -2
- package/crates/team-agent/src/provider/session/capture.rs +29 -16
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +386 -9
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +3 -14
- package/crates/team-agent/src/provider/session/context_fork.rs +7 -2
- package/crates/team-agent/src/provider/session/mod.rs +3 -2
- package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +11 -1
- package/crates/team-agent/src/provider/session_scan.rs +3 -0
- package/package.json +4 -4
|
@@ -30,6 +30,8 @@ pub(crate) fn materialize_claude_fork(
|
|
|
30
30
|
source_path: &Path,
|
|
31
31
|
source_session_id: &SessionId,
|
|
32
32
|
target_session_id: &SessionId,
|
|
33
|
+
source_agent_id: &str,
|
|
34
|
+
target_agent_id: &str,
|
|
33
35
|
) -> Result<ClaudeForkMaterialization, ProviderError> {
|
|
34
36
|
let parent = source_path.parent().ok_or_else(|| {
|
|
35
37
|
ProviderError::Io(format!(
|
|
@@ -46,7 +48,12 @@ pub(crate) fn materialize_claude_fork(
|
|
|
46
48
|
}
|
|
47
49
|
let source = std::fs::read_to_string(source_path)
|
|
48
50
|
.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
49
|
-
let rewritten = source
|
|
51
|
+
let rewritten = source
|
|
52
|
+
.replace(source_session_id.as_str(), target_session_id.as_str())
|
|
53
|
+
.replace(
|
|
54
|
+
&format!("You are Team Agent worker `{source_agent_id}`"),
|
|
55
|
+
&format!("You are Team Agent worker `{target_agent_id}`"),
|
|
56
|
+
);
|
|
50
57
|
validate_jsonl(&rewritten)?;
|
|
51
58
|
let temp = parent.join(format!(
|
|
52
59
|
".{}.tmp-{}",
|
|
@@ -108,7 +115,8 @@ mod tests {
|
|
|
108
115
|
std::fs::write(&source_path, &source).unwrap();
|
|
109
116
|
|
|
110
117
|
let mut materialized =
|
|
111
|
-
materialize_claude_fork(&source_path, &source_id, &target_id)
|
|
118
|
+
materialize_claude_fork(&source_path, &source_id, &target_id, "source", "target")
|
|
119
|
+
.unwrap();
|
|
112
120
|
let target_path = dir.join(format!("{}.jsonl", target_id.as_str()));
|
|
113
121
|
let target = std::fs::read_to_string(&target_path).unwrap();
|
|
114
122
|
assert!(!target.contains(source_id.as_str()));
|
|
@@ -29,6 +29,7 @@ pub struct CapturePassReport {
|
|
|
29
29
|
/// (coordinator tick) emits a throttled
|
|
30
30
|
/// `provider.session.transcript_missing` event for each entry.
|
|
31
31
|
pub transcript_missing: Vec<TranscriptMissing>,
|
|
32
|
+
pub(crate) context_forks: Vec<crate::lifecycle::launch::ContextForkFinalized>,
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -306,9 +307,12 @@ where
|
|
|
306
307
|
// partial candidates (no session_id or no rollout_path). When
|
|
307
308
|
// it returns false, the row stays unchanged; capture re-runs
|
|
308
309
|
// on the next coordinator tick.
|
|
309
|
-
if apply_captured_session(agent_obj, &candidate.captured) {
|
|
310
|
+
if let Some(applied) = apply_captured_session(agent_obj, &candidate.captured) {
|
|
310
311
|
report.changed = true;
|
|
311
312
|
report.assigned.push(item.agent_id);
|
|
313
|
+
if let AppliedCapture::ContextFork(context_fork) = applied {
|
|
314
|
+
report.context_forks.push(context_fork);
|
|
315
|
+
}
|
|
312
316
|
}
|
|
313
317
|
continue;
|
|
314
318
|
}
|
|
@@ -676,6 +680,17 @@ where
|
|
|
676
680
|
.and_then(Value::as_str)
|
|
677
681
|
.filter(|value| !value.is_empty())
|
|
678
682
|
.map(str::to_string);
|
|
683
|
+
let expected_session_id = agent
|
|
684
|
+
.get("_pending_session_id")
|
|
685
|
+
.and_then(Value::as_str)
|
|
686
|
+
.filter(|value| !value.is_empty())
|
|
687
|
+
.map(SessionId::new);
|
|
688
|
+
if matches!(provider, Provider::Codex)
|
|
689
|
+
&& agent.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork")
|
|
690
|
+
&& expected_session_id.is_none()
|
|
691
|
+
{
|
|
692
|
+
return None;
|
|
693
|
+
}
|
|
679
694
|
Some(PendingSessionCapture {
|
|
680
695
|
agent_id: agent_id.to_string(),
|
|
681
696
|
provider,
|
|
@@ -699,15 +714,7 @@ where
|
|
|
699
714
|
.and_then(Value::as_u64)
|
|
700
715
|
.and_then(|pid| u32::try_from(pid).ok()),
|
|
701
716
|
spawned_at,
|
|
702
|
-
expected_session_id
|
|
703
|
-
None
|
|
704
|
-
} else {
|
|
705
|
-
agent
|
|
706
|
-
.get("_pending_session_id")
|
|
707
|
-
.and_then(Value::as_str)
|
|
708
|
-
.filter(|value| !value.is_empty())
|
|
709
|
-
.map(SessionId::new)
|
|
710
|
-
},
|
|
717
|
+
expected_session_id,
|
|
711
718
|
provider_projects_root: agent
|
|
712
719
|
.get("claude_projects_root")
|
|
713
720
|
.and_then(Value::as_str)
|
|
@@ -1144,6 +1151,11 @@ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandi
|
|
|
1144
1151
|
.join("|")
|
|
1145
1152
|
}
|
|
1146
1153
|
|
|
1154
|
+
enum AppliedCapture {
|
|
1155
|
+
Session,
|
|
1156
|
+
ContextFork(crate::lifecycle::launch::ContextForkFinalized),
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1147
1159
|
/// 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 111): writes
|
|
1148
1160
|
/// the authoritative session tuple if and only if the candidate carries
|
|
1149
1161
|
/// BOTH `session_id` and `rollout_path`. A partial candidate (e.g.
|
|
@@ -1154,15 +1166,16 @@ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandi
|
|
|
1154
1166
|
fn apply_captured_session(
|
|
1155
1167
|
agent_obj: &mut serde_json::Map<String, Value>,
|
|
1156
1168
|
captured: &CapturedSession,
|
|
1157
|
-
) ->
|
|
1169
|
+
) -> Option<AppliedCapture> {
|
|
1158
1170
|
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)
|
|
1171
|
+
return crate::lifecycle::finalize_pending_fork_capture(agent_obj, captured)
|
|
1172
|
+
.map(AppliedCapture::ContextFork);
|
|
1160
1173
|
}
|
|
1161
1174
|
let Some(session_id) = captured.session_id.as_ref() else {
|
|
1162
|
-
return
|
|
1175
|
+
return None;
|
|
1163
1176
|
};
|
|
1164
1177
|
let Some(rollout_path) = captured.rollout_path.as_ref() else {
|
|
1165
|
-
return
|
|
1178
|
+
return None;
|
|
1166
1179
|
};
|
|
1167
1180
|
agent_obj.insert(
|
|
1168
1181
|
"session_id".to_string(),
|
|
@@ -1192,7 +1205,7 @@ fn apply_captured_session(
|
|
|
1192
1205
|
// diagnose/status observability.
|
|
1193
1206
|
agent_obj.remove("_pending_session_id");
|
|
1194
1207
|
agent_obj.insert("capture_state".to_string(), serde_json::json!("captured"));
|
|
1195
|
-
|
|
1208
|
+
Some(AppliedCapture::Session)
|
|
1196
1209
|
}
|
|
1197
1210
|
|
|
1198
1211
|
fn claimed_provider_session_keys(
|
|
@@ -2621,7 +2634,7 @@ mod u1_tests {
|
|
|
2621
2634
|
spawn_cwd: PathBuf::from("/tmp/cwd"),
|
|
2622
2635
|
};
|
|
2623
2636
|
let written = super::apply_captured_session(&mut agent, &captured);
|
|
2624
|
-
assert!(written, "apply must
|
|
2637
|
+
assert!(written.is_some(), "apply must accept valid captured");
|
|
2625
2638
|
assert!(
|
|
2626
2639
|
agent.get("_pending_session_id").is_none(),
|
|
2627
2640
|
"S1-CAPTURE-001: _pending_session_id must be removed after capture \
|
|
@@ -1,14 +1,319 @@
|
|
|
1
1
|
use super::*;
|
|
2
|
+
use std::collections::BTreeSet;
|
|
3
|
+
use std::fs::{File, OpenOptions};
|
|
4
|
+
use std::io::{Read, Write};
|
|
5
|
+
|
|
6
|
+
#[derive(Debug)]
|
|
7
|
+
pub(crate) struct CodexForkMaterialization {
|
|
8
|
+
path: PathBuf,
|
|
9
|
+
keep: bool,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl CodexForkMaterialization {
|
|
13
|
+
pub(crate) fn path(&self) -> &Path {
|
|
14
|
+
&self.path
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn mark_handoff(&mut self) {
|
|
18
|
+
self.keep = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl crate::provider::adapter::ForkBackingMaterialization for CodexForkMaterialization {
|
|
23
|
+
fn path(&self) -> &Path {
|
|
24
|
+
self.path()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn handoff(&mut self) {
|
|
28
|
+
self.mark_handoff();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Drop for CodexForkMaterialization {
|
|
33
|
+
fn drop(&mut self) {
|
|
34
|
+
if !self.keep {
|
|
35
|
+
let _ = std::fs::remove_file(&self.path);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pub(crate) fn materialize_codex_fork(
|
|
41
|
+
source_path: &Path,
|
|
42
|
+
source_session_id: &SessionId,
|
|
43
|
+
source_agent_id: &str,
|
|
44
|
+
target_agent_id: &str,
|
|
45
|
+
plan: &mut CommandPlan,
|
|
46
|
+
) -> Result<CodexForkMaterialization, ProviderError> {
|
|
47
|
+
let target_session_id = SessionId::new(codex_session_v7());
|
|
48
|
+
let parent = source_path.parent().ok_or_else(|| {
|
|
49
|
+
ProviderError::Io(format!(
|
|
50
|
+
"codex source backing has no parent: {}",
|
|
51
|
+
source_path.display()
|
|
52
|
+
))
|
|
53
|
+
})?;
|
|
54
|
+
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S");
|
|
55
|
+
let target_path = parent.join(format!(
|
|
56
|
+
"rollout-{timestamp}-{}.jsonl",
|
|
57
|
+
target_session_id.as_str()
|
|
58
|
+
));
|
|
59
|
+
let source = complete_source_snapshot(source_path)?;
|
|
60
|
+
let rewritten = rewrite_snapshot(
|
|
61
|
+
&source,
|
|
62
|
+
source_session_id,
|
|
63
|
+
&target_session_id,
|
|
64
|
+
source_agent_id,
|
|
65
|
+
target_agent_id,
|
|
66
|
+
)?;
|
|
67
|
+
publish_target_no_clobber(&target_path, rewritten.as_bytes())?;
|
|
68
|
+
if let Err(error) =
|
|
69
|
+
validate_materialized_target(&target_path, &target_session_id, target_agent_id)
|
|
70
|
+
{
|
|
71
|
+
let _ = std::fs::remove_file(&target_path);
|
|
72
|
+
return Err(error);
|
|
73
|
+
}
|
|
74
|
+
apply_resume_target(plan, source_session_id, &target_session_id)?;
|
|
75
|
+
Ok(CodexForkMaterialization {
|
|
76
|
+
path: target_path,
|
|
77
|
+
keep: false,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn complete_source_snapshot(path: &Path) -> Result<String, ProviderError> {
|
|
82
|
+
let file = File::open(path).map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
83
|
+
let visible_len = file
|
|
84
|
+
.metadata()
|
|
85
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?
|
|
86
|
+
.len();
|
|
87
|
+
let mut bytes = Vec::new();
|
|
88
|
+
file.take(visible_len)
|
|
89
|
+
.read_to_end(&mut bytes)
|
|
90
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
91
|
+
let Some(boundary) = bytes.iter().rposition(|byte| *byte == b'\n') else {
|
|
92
|
+
return Err(ProviderError::Io(
|
|
93
|
+
"codex fork source has no complete JSONL record".to_string(),
|
|
94
|
+
));
|
|
95
|
+
};
|
|
96
|
+
bytes.truncate(boundary + 1);
|
|
97
|
+
String::from_utf8(bytes).map_err(|error| ProviderError::Io(error.to_string()))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn rewrite_snapshot(
|
|
101
|
+
source: &str,
|
|
102
|
+
source_session_id: &SessionId,
|
|
103
|
+
target_session_id: &SessionId,
|
|
104
|
+
source_agent_id: &str,
|
|
105
|
+
target_agent_id: &str,
|
|
106
|
+
) -> Result<String, ProviderError> {
|
|
107
|
+
let source_marker = format!("You are Team Agent worker `{source_agent_id}`");
|
|
108
|
+
let target_marker = format!("You are Team Agent worker `{target_agent_id}`");
|
|
109
|
+
let mut meta_count = 0_usize;
|
|
110
|
+
let mut marker_count = 0_usize;
|
|
111
|
+
let mut output = String::new();
|
|
112
|
+
for (index, line) in source.lines().enumerate() {
|
|
113
|
+
if line.trim().is_empty() {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let mut record = serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
|
|
117
|
+
ProviderError::Io(format!(
|
|
118
|
+
"codex fork source has invalid JSONL at line {}: {error}",
|
|
119
|
+
index + 1
|
|
120
|
+
))
|
|
121
|
+
})?;
|
|
122
|
+
if record.get("type").and_then(serde_json::Value::as_str) == Some("session_meta") {
|
|
123
|
+
let id = record
|
|
124
|
+
.get_mut("payload")
|
|
125
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
126
|
+
.and_then(|payload| payload.get_mut("id"))
|
|
127
|
+
.and_then(|id| id.as_str())
|
|
128
|
+
.ok_or_else(|| {
|
|
129
|
+
ProviderError::Io(
|
|
130
|
+
"codex fork session_meta has no string payload.id".to_string(),
|
|
131
|
+
)
|
|
132
|
+
})?;
|
|
133
|
+
if id != source_session_id.as_str() {
|
|
134
|
+
return Err(ProviderError::Io(
|
|
135
|
+
"codex fork session_meta id does not match source".to_string(),
|
|
136
|
+
));
|
|
137
|
+
}
|
|
138
|
+
record["payload"]["id"] =
|
|
139
|
+
serde_json::Value::String(target_session_id.as_str().to_string());
|
|
140
|
+
meta_count += 1;
|
|
141
|
+
}
|
|
142
|
+
marker_count += replace_exact_marker(&mut record, &source_marker, &target_marker);
|
|
143
|
+
output.push_str(
|
|
144
|
+
&serde_json::to_string(&record)
|
|
145
|
+
.map_err(|error| ProviderError::Io(error.to_string()))?,
|
|
146
|
+
);
|
|
147
|
+
output.push('\n');
|
|
148
|
+
}
|
|
149
|
+
if meta_count != 1 || marker_count != 1 {
|
|
150
|
+
return Err(ProviderError::Io(format!(
|
|
151
|
+
"codex fork source identity is ambiguous: session_meta={meta_count}, marker={marker_count}"
|
|
152
|
+
)));
|
|
153
|
+
}
|
|
154
|
+
Ok(output)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn replace_exact_marker(value: &mut serde_json::Value, source: &str, target: &str) -> usize {
|
|
158
|
+
match value {
|
|
159
|
+
serde_json::Value::String(text) => {
|
|
160
|
+
let count = text.matches(source).count();
|
|
161
|
+
if count == 1 {
|
|
162
|
+
*text = text.replacen(source, target, 1);
|
|
163
|
+
}
|
|
164
|
+
count
|
|
165
|
+
}
|
|
166
|
+
serde_json::Value::Array(items) => items
|
|
167
|
+
.iter_mut()
|
|
168
|
+
.map(|item| replace_exact_marker(item, source, target))
|
|
169
|
+
.sum(),
|
|
170
|
+
serde_json::Value::Object(fields) => fields
|
|
171
|
+
.values_mut()
|
|
172
|
+
.map(|item| replace_exact_marker(item, source, target))
|
|
173
|
+
.sum(),
|
|
174
|
+
_ => 0,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn publish_target_no_clobber(path: &Path, bytes: &[u8]) -> Result<(), ProviderError> {
|
|
179
|
+
let parent = path.parent().ok_or_else(|| {
|
|
180
|
+
ProviderError::Io(format!("codex target has no parent: {}", path.display()))
|
|
181
|
+
})?;
|
|
182
|
+
let temp = parent.join(format!(
|
|
183
|
+
".{}.tmp-{}",
|
|
184
|
+
path.file_name()
|
|
185
|
+
.and_then(|name| name.to_str())
|
|
186
|
+
.unwrap_or("codex-fork"),
|
|
187
|
+
std::process::id()
|
|
188
|
+
));
|
|
189
|
+
let result = (|| -> std::io::Result<()> {
|
|
190
|
+
let mut file = OpenOptions::new()
|
|
191
|
+
.write(true)
|
|
192
|
+
.create_new(true)
|
|
193
|
+
.open(&temp)?;
|
|
194
|
+
file.write_all(bytes)?;
|
|
195
|
+
file.sync_all()?;
|
|
196
|
+
std::fs::hard_link(&temp, path)?;
|
|
197
|
+
File::open(parent)?.sync_all()?;
|
|
198
|
+
std::fs::remove_file(&temp)?;
|
|
199
|
+
Ok(())
|
|
200
|
+
})();
|
|
201
|
+
if let Err(error) = result {
|
|
202
|
+
let _ = std::fs::remove_file(&temp);
|
|
203
|
+
return Err(ProviderError::Io(error.to_string()));
|
|
204
|
+
}
|
|
205
|
+
Ok(())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn validate_materialized_target(
|
|
209
|
+
path: &Path,
|
|
210
|
+
session_id: &SessionId,
|
|
211
|
+
target_agent_id: &str,
|
|
212
|
+
) -> Result<(), ProviderError> {
|
|
213
|
+
let text =
|
|
214
|
+
std::fs::read_to_string(path).map_err(|error| ProviderError::Io(error.to_string()))?;
|
|
215
|
+
let actual = session_id_from_jsonl(path);
|
|
216
|
+
let marker = format!("You are Team Agent worker `{target_agent_id}`");
|
|
217
|
+
if actual.as_deref() != Some(session_id.as_str())
|
|
218
|
+
|| !path
|
|
219
|
+
.file_stem()
|
|
220
|
+
.and_then(|name| name.to_str())
|
|
221
|
+
.is_some_and(|name| name.ends_with(session_id.as_str()))
|
|
222
|
+
|| text.matches(&marker).count() != 1
|
|
223
|
+
{
|
|
224
|
+
return Err(ProviderError::Io(
|
|
225
|
+
"codex fork target read-back identity mismatch".to_string(),
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
Ok(())
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fn apply_resume_target(
|
|
232
|
+
plan: &mut CommandPlan,
|
|
233
|
+
source_session_id: &SessionId,
|
|
234
|
+
target_session_id: &SessionId,
|
|
235
|
+
) -> Result<(), ProviderError> {
|
|
236
|
+
if plan.argv.first().map(String::as_str) != Some("codex")
|
|
237
|
+
|| plan.argv.get(1).map(String::as_str) != Some("fork")
|
|
238
|
+
|| plan.argv.last().map(String::as_str) != Some(source_session_id.as_str())
|
|
239
|
+
{
|
|
240
|
+
return Err(ProviderError::Command(
|
|
241
|
+
"codex fork command shape cannot be converted to exact resume".to_string(),
|
|
242
|
+
));
|
|
243
|
+
}
|
|
244
|
+
plan.argv[1] = "resume".to_string();
|
|
245
|
+
*plan.argv.last_mut().expect("validated non-empty argv") =
|
|
246
|
+
target_session_id.as_str().to_string();
|
|
247
|
+
plan.expected_session_id = Some(target_session_id.clone());
|
|
248
|
+
Ok(())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn codex_session_v7() -> String {
|
|
252
|
+
use sha2::Digest;
|
|
253
|
+
|
|
254
|
+
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
255
|
+
|
|
256
|
+
let millis = std::time::SystemTime::now()
|
|
257
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
258
|
+
.map_or(0, |duration| duration.as_millis() as u64);
|
|
259
|
+
let mut hasher = sha2::Sha256::new();
|
|
260
|
+
hasher.update(millis.to_le_bytes());
|
|
261
|
+
hasher.update(std::process::id().to_le_bytes());
|
|
262
|
+
hasher.update(
|
|
263
|
+
COUNTER
|
|
264
|
+
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
265
|
+
.to_le_bytes(),
|
|
266
|
+
);
|
|
267
|
+
let digest = hasher.finalize();
|
|
268
|
+
let mut bytes = [0_u8; 16];
|
|
269
|
+
bytes[..6].copy_from_slice(&millis.to_be_bytes()[2..]);
|
|
270
|
+
bytes[6..].copy_from_slice(&digest[..10]);
|
|
271
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
272
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
273
|
+
format!(
|
|
274
|
+
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
|
|
275
|
+
u32::from_be_bytes(bytes[0..4].try_into().expect("fixed slice")),
|
|
276
|
+
u16::from_be_bytes(bytes[4..6].try_into().expect("fixed slice")),
|
|
277
|
+
u16::from_be_bytes(bytes[6..8].try_into().expect("fixed slice")),
|
|
278
|
+
u16::from_be_bytes(bytes[8..10].try_into().expect("fixed slice")),
|
|
279
|
+
u64::from_be_bytes([
|
|
280
|
+
0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
|
|
281
|
+
])
|
|
282
|
+
)
|
|
283
|
+
}
|
|
2
284
|
|
|
3
285
|
pub(super) fn verify_codex_fork(
|
|
4
286
|
source_session_id: &SessionId,
|
|
5
287
|
plan: &CommandPlan,
|
|
6
288
|
before: &ContextBackingSnapshot,
|
|
289
|
+
expected_backing_path: Option<&Path>,
|
|
7
290
|
agent_id: &str,
|
|
8
291
|
spawn_cwd: &Path,
|
|
9
292
|
spawned_at: &str,
|
|
10
293
|
deadline: Duration,
|
|
11
294
|
) -> Result<ContextForkProof, ContextForkTermination> {
|
|
295
|
+
let Some(expected) = plan.expected_session_id.as_ref() else {
|
|
296
|
+
return verify_legacy_codex_fork(
|
|
297
|
+
source_session_id,
|
|
298
|
+
plan,
|
|
299
|
+
before,
|
|
300
|
+
agent_id,
|
|
301
|
+
spawn_cwd,
|
|
302
|
+
spawned_at,
|
|
303
|
+
deadline,
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
if expected == source_session_id {
|
|
307
|
+
return Err(ProviderError::CaptureFailed(
|
|
308
|
+
"context_fork_unverified: codex target session equals source".to_string(),
|
|
309
|
+
)
|
|
310
|
+
.into());
|
|
311
|
+
}
|
|
312
|
+
let expected_path = expected_backing_path.ok_or_else(|| {
|
|
313
|
+
ProviderError::CaptureFailed(
|
|
314
|
+
"context_fork_unverified: codex plan has no materialized target backing".to_string(),
|
|
315
|
+
)
|
|
316
|
+
})?;
|
|
12
317
|
let context = crate::provider::session_scan::CaptureSessionContext {
|
|
13
318
|
agent_id: agent_id.to_string(),
|
|
14
319
|
spawn_cwd: spawn_cwd.to_path_buf(),
|
|
@@ -18,28 +323,25 @@ pub(super) fn verify_codex_fork(
|
|
|
18
323
|
expected_session_id: plan.expected_session_id.clone(),
|
|
19
324
|
provider_projects_root: plan.provider_projects_root.clone(),
|
|
20
325
|
};
|
|
21
|
-
let excluded = outcome::source_exclusions(before, source_session_id);
|
|
22
326
|
let started = std::time::Instant::now();
|
|
23
327
|
loop {
|
|
24
|
-
let current = jsonl_files(&before.root);
|
|
25
328
|
for candidate in
|
|
26
329
|
crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
|
|
27
330
|
{
|
|
28
331
|
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
29
332
|
continue;
|
|
30
333
|
};
|
|
31
|
-
|
|
32
|
-
continue;
|
|
33
|
-
};
|
|
34
|
-
let snapshot_changed = before.files.get(path.as_path()) != Some(stamp);
|
|
35
|
-
if !snapshot_changed {
|
|
334
|
+
if path.as_path() != expected_path {
|
|
36
335
|
continue;
|
|
37
336
|
}
|
|
38
337
|
let Some(new_session_id) = candidate.captured.session_id else {
|
|
39
338
|
continue;
|
|
40
339
|
};
|
|
41
|
-
if
|
|
42
|
-
|
|
340
|
+
if &new_session_id != expected || &new_session_id == source_session_id {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if !candidate.positive_agent_id_match
|
|
344
|
+
|| candidate.embedded_agent_id.as_deref() != Some(agent_id)
|
|
43
345
|
{
|
|
44
346
|
continue;
|
|
45
347
|
}
|
|
@@ -63,3 +365,78 @@ pub(super) fn verify_codex_fork(
|
|
|
63
365
|
deadline_ms: deadline.as_millis(),
|
|
64
366
|
})
|
|
65
367
|
}
|
|
368
|
+
|
|
369
|
+
fn verify_legacy_codex_fork(
|
|
370
|
+
source_session_id: &SessionId,
|
|
371
|
+
plan: &CommandPlan,
|
|
372
|
+
before: &ContextBackingSnapshot,
|
|
373
|
+
agent_id: &str,
|
|
374
|
+
spawn_cwd: &Path,
|
|
375
|
+
spawned_at: &str,
|
|
376
|
+
deadline: Duration,
|
|
377
|
+
) -> Result<ContextForkProof, ContextForkTermination> {
|
|
378
|
+
let mut excluded_session_ids = BTreeSet::new();
|
|
379
|
+
excluded_session_ids.insert(source_session_id.as_str().to_string());
|
|
380
|
+
let mut excluded_backing_paths = BTreeSet::new();
|
|
381
|
+
for path in before.files.keys() {
|
|
382
|
+
if session_id_from_jsonl(path).as_deref() == Some(source_session_id.as_str()) {
|
|
383
|
+
excluded_backing_paths.insert(path.clone());
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
let context = crate::provider::session_scan::CaptureSessionContext {
|
|
387
|
+
agent_id: agent_id.to_string(),
|
|
388
|
+
spawn_cwd: spawn_cwd.to_path_buf(),
|
|
389
|
+
pane_id: None,
|
|
390
|
+
pane_pid: None,
|
|
391
|
+
spawned_at: Some(spawned_at.to_string()),
|
|
392
|
+
expected_session_id: None,
|
|
393
|
+
provider_projects_root: plan.provider_projects_root.clone(),
|
|
394
|
+
};
|
|
395
|
+
let started = std::time::Instant::now();
|
|
396
|
+
loop {
|
|
397
|
+
let current = jsonl_files(&provider_backing_root(Provider::Codex, plan));
|
|
398
|
+
let mut matches = Vec::new();
|
|
399
|
+
for candidate in
|
|
400
|
+
crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
|
|
401
|
+
{
|
|
402
|
+
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
403
|
+
continue;
|
|
404
|
+
};
|
|
405
|
+
if excluded_backing_paths.contains(path.as_path()) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
let Some(stamp) = current.get(path.as_path()) else {
|
|
409
|
+
continue;
|
|
410
|
+
};
|
|
411
|
+
if before.files.get(path.as_path()) == Some(stamp) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
let Some(session_id) = candidate.captured.session_id else {
|
|
415
|
+
continue;
|
|
416
|
+
};
|
|
417
|
+
if excluded_session_ids.contains(session_id.as_str()) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
matches.push((session_id, path.as_path().to_path_buf()));
|
|
421
|
+
}
|
|
422
|
+
if let [(new_session_id, backing_path)] = matches.as_slice() {
|
|
423
|
+
return Ok(ContextForkProof {
|
|
424
|
+
provider: Provider::Codex,
|
|
425
|
+
source_session_id: source_session_id.clone(),
|
|
426
|
+
new_session_id: new_session_id.clone(),
|
|
427
|
+
backing_path: backing_path.clone(),
|
|
428
|
+
captured_via: "context_fork_verified".to_string(),
|
|
429
|
+
attribution_confidence: "high".to_string(),
|
|
430
|
+
managed_backing_root: None,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if started.elapsed() >= deadline {
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
437
|
+
}
|
|
438
|
+
Err(ContextForkTermination::Timeout {
|
|
439
|
+
provider: Provider::Codex,
|
|
440
|
+
deadline_ms: deadline.as_millis(),
|
|
441
|
+
})
|
|
442
|
+
}
|
|
@@ -1,18 +1,4 @@
|
|
|
1
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
2
|
|
|
17
3
|
#[derive(Debug, Clone)]
|
|
18
4
|
pub(crate) struct PendingContextFork {
|
|
@@ -47,6 +33,7 @@ pub(crate) fn observe_context_fork(
|
|
|
47
33
|
plan: &CommandPlan,
|
|
48
34
|
before: &ContextBackingSnapshot,
|
|
49
35
|
expected_backing_path: Option<&Path>,
|
|
36
|
+
source_agent_id: &str,
|
|
50
37
|
agent_id: &str,
|
|
51
38
|
spawn_cwd: &Path,
|
|
52
39
|
spawned_at: &str,
|
|
@@ -58,6 +45,7 @@ pub(crate) fn observe_context_fork(
|
|
|
58
45
|
plan,
|
|
59
46
|
before,
|
|
60
47
|
expected_backing_path,
|
|
48
|
+
source_agent_id,
|
|
61
49
|
agent_id,
|
|
62
50
|
spawn_cwd,
|
|
63
51
|
spawned_at,
|
|
@@ -123,6 +111,7 @@ mod tests {
|
|
|
123
111
|
&plan,
|
|
124
112
|
&before,
|
|
125
113
|
None,
|
|
114
|
+
"source",
|
|
126
115
|
"fork",
|
|
127
116
|
&root,
|
|
128
117
|
"2026-07-24T00:00:00Z",
|
|
@@ -21,6 +21,7 @@ pub(crate) enum ContextForkTermination {
|
|
|
21
21
|
mod claude;
|
|
22
22
|
mod codex;
|
|
23
23
|
mod outcome;
|
|
24
|
+
pub(crate) use codex::materialize_codex_fork;
|
|
24
25
|
pub(crate) use outcome::{
|
|
25
26
|
observe_context_fork, transition_pending_context_fork, ContextForkOutcome, PendingContextFork,
|
|
26
27
|
};
|
|
@@ -47,7 +48,6 @@ pub struct ContextForkProof {
|
|
|
47
48
|
|
|
48
49
|
#[derive(Debug, Clone)]
|
|
49
50
|
pub(crate) struct ContextBackingSnapshot {
|
|
50
|
-
root: PathBuf,
|
|
51
51
|
files: BTreeMap<PathBuf, FileStamp>,
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -61,7 +61,7 @@ impl ContextBackingSnapshot {
|
|
|
61
61
|
pub(crate) fn capture(provider: Provider, plan: &CommandPlan) -> Self {
|
|
62
62
|
let root = provider_backing_root(provider, plan);
|
|
63
63
|
let files = jsonl_files(&root);
|
|
64
|
-
Self {
|
|
64
|
+
Self { files }
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -71,6 +71,7 @@ pub(crate) fn verify_context_fork(
|
|
|
71
71
|
plan: &CommandPlan,
|
|
72
72
|
before: &ContextBackingSnapshot,
|
|
73
73
|
expected_backing_path: Option<&Path>,
|
|
74
|
+
_source_agent_id: &str,
|
|
74
75
|
agent_id: &str,
|
|
75
76
|
spawn_cwd: &Path,
|
|
76
77
|
spawned_at: &str,
|
|
@@ -84,6 +85,7 @@ pub(crate) fn verify_context_fork(
|
|
|
84
85
|
source_session_id,
|
|
85
86
|
plan,
|
|
86
87
|
before,
|
|
88
|
+
expected_backing_path,
|
|
87
89
|
agent_id,
|
|
88
90
|
spawn_cwd,
|
|
89
91
|
spawned_at,
|
|
@@ -298,6 +300,7 @@ mod tests {
|
|
|
298
300
|
&plan,
|
|
299
301
|
&before,
|
|
300
302
|
None,
|
|
303
|
+
"source",
|
|
301
304
|
"fork",
|
|
302
305
|
¤t_cwd,
|
|
303
306
|
"2026-07-21T20:00:00+00:00",
|
|
@@ -344,6 +347,7 @@ mod tests {
|
|
|
344
347
|
&plan,
|
|
345
348
|
&before,
|
|
346
349
|
Some(&expected_path),
|
|
350
|
+
"source",
|
|
347
351
|
"fork",
|
|
348
352
|
&root,
|
|
349
353
|
"2026-07-22T00:00:00Z",
|
|
@@ -382,6 +386,7 @@ mod tests {
|
|
|
382
386
|
&plan,
|
|
383
387
|
&before,
|
|
384
388
|
Some(&missing),
|
|
389
|
+
"source",
|
|
385
390
|
"fork",
|
|
386
391
|
&root,
|
|
387
392
|
"2026-07-22T00:00:00Z",
|
|
@@ -10,8 +10,9 @@ pub mod resume;
|
|
|
10
10
|
|
|
11
11
|
pub use context_fork::ContextForkProof;
|
|
12
12
|
pub(crate) use context_fork::{
|
|
13
|
-
context_fork_convergence_deadline,
|
|
14
|
-
ContextBackingSnapshot, ContextForkOutcome,
|
|
13
|
+
context_fork_convergence_deadline, materialize_codex_fork, observe_context_fork,
|
|
14
|
+
transition_pending_context_fork, ContextBackingSnapshot, ContextForkOutcome,
|
|
15
|
+
PendingContextFork,
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
pub use resume::{
|