@team-agent/installer 0.5.59 → 0.5.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +44 -1
- package/crates/team-agent/src/cli/diagnose.rs +183 -0
- package/crates/team-agent/src/cli/emit.rs +63 -14
- package/crates/team-agent/src/cli/leader.rs +8 -1
- package/crates/team-agent/src/cli/mod.rs +263 -5
- package/crates/team-agent/src/cli/named_address.rs +18 -1
- package/crates/team-agent/src/cli/send/presentation.rs +2 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/status_port/store.rs +7 -3
- package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
- package/crates/team-agent/src/cli/types.rs +33 -0
- package/crates/team-agent/src/communication_mode/mod.rs +53 -0
- package/crates/team-agent/src/compiler/tests.rs +5 -5
- package/crates/team-agent/src/compiler.rs +53 -1
- package/crates/team-agent/src/coordinator/tick.rs +21 -8
- package/crates/team-agent/src/db/message_store.rs +45 -1
- package/crates/team-agent/src/fake_worker.rs +21 -1
- package/crates/team-agent/src/leader/start.rs +732 -94
- package/crates/team-agent/src/leader/tests/identity.rs +64 -57
- package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +57 -51
- 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/spawn.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.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/lifecycle/worker_command_context.rs +29 -11
- package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
- package/crates/team-agent/src/mcp_server/tools.rs +100 -66
- package/crates/team-agent/src/mcp_server/types.rs +5 -0
- package/crates/team-agent/src/mcp_server/wire.rs +27 -21
- package/crates/team-agent/src/messaging/delivery.rs +105 -38
- package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
- package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
- package/crates/team-agent/src/messaging/presentation.rs +109 -0
- package/crates/team-agent/src/messaging/results.rs +165 -17
- package/crates/team-agent/src/messaging/send.rs +94 -81
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
- package/crates/team-agent/src/messaging/types.rs +5 -0
- package/crates/team-agent/src/messaging/watchers.rs +5 -0
- package/crates/team-agent/src/model/mod.rs +1 -0
- package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
- package/crates/team-agent/src/model/spec.rs +16 -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 +69 -34
- 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/claude.rs +92 -3
- package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +34 -8
- package/crates/team-agent/src/provider/session_scan.rs +8 -0
- package/crates/team-agent/src/topology.rs +3 -0
- package/package.json +4 -4
- package/skills/team-agent/command-coverage.json +379 -0
|
@@ -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::{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
use std::path::{Path, PathBuf};
|
|
2
2
|
|
|
3
|
-
use crate::provider::
|
|
3
|
+
use crate::provider::helpers::find_session_id;
|
|
4
|
+
use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
|
|
4
5
|
use crate::provider::Provider;
|
|
5
6
|
|
|
6
7
|
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
@@ -14,7 +15,7 @@ pub(crate) fn projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<Path
|
|
|
14
15
|
Some(home.join(".claude").join("projects").join(encoded))
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
fn encode_projects_dir(path: &str) -> String {
|
|
18
|
+
pub(super) fn encode_projects_dir(path: &str) -> String {
|
|
18
19
|
let mut out = String::with_capacity(path.len());
|
|
19
20
|
for c in path.chars() {
|
|
20
21
|
if c.is_ascii_alphanumeric() {
|
|
@@ -26,6 +27,65 @@ fn encode_projects_dir(path: &str) -> String {
|
|
|
26
27
|
out
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
pub(super) fn scan_expected_session(
|
|
31
|
+
context: &CaptureSessionContext,
|
|
32
|
+
) -> Vec<CapturedSessionCandidate> {
|
|
33
|
+
let Some(expected) = context.expected_session_id.as_ref() else {
|
|
34
|
+
return Vec::new();
|
|
35
|
+
};
|
|
36
|
+
let Some(projects_root) = context.provider_projects_root.clone().or_else(|| {
|
|
37
|
+
std::env::var_os("HOME")
|
|
38
|
+
.map(PathBuf::from)
|
|
39
|
+
.map(|home| home.join(".claude").join("projects"))
|
|
40
|
+
}) else {
|
|
41
|
+
return Vec::new();
|
|
42
|
+
};
|
|
43
|
+
let canonical =
|
|
44
|
+
std::fs::canonicalize(&context.spawn_cwd).unwrap_or_else(|_| context.spawn_cwd.clone());
|
|
45
|
+
let encoded = encode_projects_dir(&canonical.to_string_lossy());
|
|
46
|
+
if encoded.is_empty() {
|
|
47
|
+
return Vec::new();
|
|
48
|
+
}
|
|
49
|
+
let path = projects_root
|
|
50
|
+
.join(encoded)
|
|
51
|
+
.join(format!("{}.jsonl", expected.as_str()));
|
|
52
|
+
let Ok(text) = super::common::read_head_text(&path, super::common::CAPTURE_HEAD_BYTES) else {
|
|
53
|
+
return Vec::new();
|
|
54
|
+
};
|
|
55
|
+
let records = super::common::parse_session_records(&text);
|
|
56
|
+
let session_matches = records
|
|
57
|
+
.iter()
|
|
58
|
+
.find_map(find_session_id)
|
|
59
|
+
.is_some_and(|session_id| session_id == expected.as_str());
|
|
60
|
+
let has_lifecycle_record = records.iter().any(|record| {
|
|
61
|
+
matches!(
|
|
62
|
+
record.get("type").and_then(serde_json::Value::as_str),
|
|
63
|
+
Some("user" | "assistant")
|
|
64
|
+
)
|
|
65
|
+
});
|
|
66
|
+
if !session_matches
|
|
67
|
+
|| !has_lifecycle_record
|
|
68
|
+
|| records_have_leader_marker(&records)
|
|
69
|
+
|| !records.iter().any(has_cwd_field)
|
|
70
|
+
{
|
|
71
|
+
return Vec::new();
|
|
72
|
+
}
|
|
73
|
+
let embedded_agent_id = super::common::embedded_team_agent_worker_id_from_text(&text);
|
|
74
|
+
let positive_agent_id_match = embedded_agent_id.as_deref() == Some(context.agent_id.as_str());
|
|
75
|
+
vec![CapturedSessionCandidate {
|
|
76
|
+
captured: CapturedSession {
|
|
77
|
+
session_id: Some(expected.clone()),
|
|
78
|
+
rollout_path: Some(RolloutPath::new(path)),
|
|
79
|
+
captured_via: CaptureVia::FsWatch,
|
|
80
|
+
attribution_confidence: Confidence::High,
|
|
81
|
+
spawn_cwd: context.spawn_cwd.clone(),
|
|
82
|
+
},
|
|
83
|
+
embedded_agent_id,
|
|
84
|
+
positive_agent_id_match,
|
|
85
|
+
agent_path_match: false,
|
|
86
|
+
}]
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
pub(crate) fn rollout_path_has_leader_marker(provider: Provider, rollout_path: &Path) -> bool {
|
|
30
90
|
if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
31
91
|
return false;
|
|
@@ -112,6 +172,7 @@ mod tests {
|
|
|
112
172
|
std::fs::create_dir_all(dir).unwrap();
|
|
113
173
|
let path = dir.join(format!("{uuid}.jsonl"));
|
|
114
174
|
let line = serde_json::json!({
|
|
175
|
+
"type": "user",
|
|
115
176
|
"sessionId": uuid,
|
|
116
177
|
"cwd": cwd.to_string_lossy(),
|
|
117
178
|
});
|
|
@@ -119,6 +180,11 @@ mod tests {
|
|
|
119
180
|
path
|
|
120
181
|
}
|
|
121
182
|
|
|
183
|
+
fn expected_transcript_dir(projects_root: &Path, cwd: &Path) -> PathBuf {
|
|
184
|
+
let canonical = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
|
|
185
|
+
projects_root.join(encode_projects_dir(&canonical.to_string_lossy()))
|
|
186
|
+
}
|
|
187
|
+
|
|
122
188
|
#[test]
|
|
123
189
|
fn claude_leader_marker_in_custom_title_is_detected() {
|
|
124
190
|
let records = vec![serde_json::json!({
|
|
@@ -205,7 +271,11 @@ mod tests {
|
|
|
205
271
|
std::fs::create_dir_all(&cwd).unwrap();
|
|
206
272
|
let proj = base.join("projects");
|
|
207
273
|
write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
|
|
208
|
-
write_transcript(
|
|
274
|
+
let expected = write_transcript(
|
|
275
|
+
&expected_transcript_dir(&proj, &cwd),
|
|
276
|
+
"22222222-2222-4222-8222-222222222222",
|
|
277
|
+
&cwd,
|
|
278
|
+
);
|
|
209
279
|
let ctx = CaptureSessionContext {
|
|
210
280
|
agent_id: "w1".to_string(),
|
|
211
281
|
spawn_cwd: cwd.clone(),
|
|
@@ -221,6 +291,10 @@ mod tests {
|
|
|
221
291
|
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
222
292
|
"22222222-2222-4222-8222-222222222222"
|
|
223
293
|
);
|
|
294
|
+
assert_eq!(
|
|
295
|
+
out[0].captured.rollout_path.as_ref().unwrap().as_path(),
|
|
296
|
+
expected
|
|
297
|
+
);
|
|
224
298
|
let _ = std::fs::remove_dir_all(&base);
|
|
225
299
|
}
|
|
226
300
|
|
|
@@ -284,6 +358,21 @@ mod tests {
|
|
|
284
358
|
let proj = base.join("projects");
|
|
285
359
|
let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
|
|
286
360
|
let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
|
|
361
|
+
let addressed =
|
|
362
|
+
expected_transcript_dir(&proj, &cwd).join("99999999-9999-4999-8999-999999999999.jsonl");
|
|
363
|
+
std::fs::create_dir_all(addressed.parent().unwrap()).unwrap();
|
|
364
|
+
std::fs::write(
|
|
365
|
+
&addressed,
|
|
366
|
+
format!(
|
|
367
|
+
"{}\n",
|
|
368
|
+
serde_json::json!({
|
|
369
|
+
"type": "user",
|
|
370
|
+
"sessionId": "22222222-2222-4222-8222-222222222222",
|
|
371
|
+
"cwd": cwd.to_string_lossy(),
|
|
372
|
+
})
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
.unwrap();
|
|
287
376
|
let _ = (leader, stale);
|
|
288
377
|
let ctx = CaptureSessionContext {
|
|
289
378
|
agent_id: "claude-worker".to_string(),
|