@team-agent/installer 0.5.58 → 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.
Files changed (27) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +30 -2
  4. package/crates/team-agent/src/cli/send/mailbox.rs +68 -25
  5. package/crates/team-agent/src/cli/spec.rs +1 -1
  6. package/crates/team-agent/src/coordinator/tick.rs +21 -8
  7. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
  8. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +56 -50
  9. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +148 -8
  10. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +5 -0
  11. package/crates/team-agent/src/lifecycle/launch.rs +1 -1
  12. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +35 -1
  13. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  14. package/crates/team-agent/src/mcp_server/normalize.rs +53 -1
  15. package/crates/team-agent/src/mcp_server/tools.rs +8 -1
  16. package/crates/team-agent/src/provider/adapter.rs +57 -0
  17. package/crates/team-agent/src/provider/adapters/claude_fork.rs +10 -2
  18. package/crates/team-agent/src/provider/session/capture.rs +29 -16
  19. package/crates/team-agent/src/provider/session/context_fork/codex.rs +386 -9
  20. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +3 -14
  21. package/crates/team-agent/src/provider/session/context_fork.rs +7 -2
  22. package/crates/team-agent/src/provider/session/mod.rs +3 -2
  23. package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
  24. package/crates/team-agent/src/provider/session_scan/common.rs +11 -1
  25. package/crates/team-agent/src/provider/session_scan.rs +3 -0
  26. package/package.json +4 -4
  27. package/skills/team-agent/SKILL.md +1 -0
@@ -44,6 +44,8 @@ pub(super) fn prepare_claude_fork_backing(
44
44
  plan: &crate::provider::CommandPlan,
45
45
  source_backing: &Path,
46
46
  source_session_id: &crate::provider::SessionId,
47
+ source_agent_id: &AgentId,
48
+ target_agent_id: &AgentId,
47
49
  ) -> Result<Option<crate::provider::adapters::claude_fork::ClaudeForkMaterialization>, LifecycleError>
48
50
  {
49
51
  if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
@@ -56,6 +58,8 @@ pub(super) fn prepare_claude_fork_backing(
56
58
  source_backing,
57
59
  source_session_id,
58
60
  target_session_id,
61
+ source_agent_id.as_str(),
62
+ target_agent_id.as_str(),
59
63
  )
60
64
  .map(Some)
61
65
  .map_err(|error| {
@@ -122,7 +126,75 @@ pub(super) struct ForkFinalizeInput<'a> {
122
126
  pub spawn_epoch: u64,
123
127
  }
124
128
 
125
- pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), LifecycleError> {
129
+ #[derive(Debug, Clone, PartialEq, Eq)]
130
+ pub(crate) struct ContextForkFinalized {
131
+ source_agent_id: String,
132
+ agent_id: String,
133
+ session_id: String,
134
+ rollout_path: String,
135
+ captured_via: String,
136
+ attribution_confidence: String,
137
+ }
138
+
139
+ impl ContextForkFinalized {
140
+ fn new(
141
+ source_agent_id: &str,
142
+ agent_id: &str,
143
+ session_id: &str,
144
+ rollout_path: &Path,
145
+ captured_via: &str,
146
+ attribution_confidence: &str,
147
+ ) -> Self {
148
+ Self {
149
+ source_agent_id: source_agent_id.to_string(),
150
+ agent_id: agent_id.to_string(),
151
+ session_id: session_id.to_string(),
152
+ rollout_path: rollout_path.to_string_lossy().to_string(),
153
+ captured_via: captured_via.to_string(),
154
+ attribution_confidence: attribution_confidence.to_string(),
155
+ }
156
+ }
157
+
158
+ fn from_captured(
159
+ source_agent_id: &str,
160
+ agent_id: &str,
161
+ captured: &crate::provider::CapturedSession,
162
+ ) -> Option<Self> {
163
+ let captured_via = serde_json::to_value(captured.captured_via).ok()?;
164
+ let attribution_confidence = serde_json::to_value(captured.attribution_confidence).ok()?;
165
+ Some(Self::new(
166
+ source_agent_id,
167
+ agent_id,
168
+ captured.session_id.as_ref()?.as_str(),
169
+ captured.rollout_path.as_ref()?.as_path(),
170
+ captured_via.as_str()?,
171
+ attribution_confidence.as_str()?,
172
+ ))
173
+ }
174
+
175
+ pub(crate) fn write_audit(
176
+ &self,
177
+ event_log: &crate::event_log::EventLog,
178
+ ) -> Result<(), crate::event_log::EventLogError> {
179
+ event_log
180
+ .write(
181
+ crate::lifecycle::types::event_names::CONTEXT_FORK,
182
+ serde_json::json!({
183
+ "source_agent_id": self.source_agent_id,
184
+ "agent_id": self.agent_id,
185
+ "session_id": self.session_id,
186
+ "rollout_path": self.rollout_path,
187
+ "captured_via": self.captured_via,
188
+ "attribution_confidence": self.attribution_confidence,
189
+ }),
190
+ )
191
+ .map(|_| ())
192
+ }
193
+ }
194
+
195
+ pub(super) fn finalize_fork_state(
196
+ input: ForkFinalizeInput<'_>,
197
+ ) -> Result<ContextForkFinalized, LifecycleError> {
126
198
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
127
199
  workspace: input.workspace,
128
200
  operation: "fork-agent-finalize",
@@ -169,7 +241,15 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
169
241
  },
170
242
  &next_state,
171
243
  )
172
- .map_err(|error| LifecycleError::StatePersist(error.to_string()))
244
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
245
+ Ok(ContextForkFinalized::new(
246
+ input.source_agent_id.as_str(),
247
+ input.agent_id.as_str(),
248
+ input.context_proof.new_session_id.as_str(),
249
+ &input.context_proof.backing_path,
250
+ &input.context_proof.captured_via,
251
+ &input.context_proof.attribution_confidence,
252
+ ))
173
253
  }
174
254
 
175
255
  pub(super) struct ForkPendingFinalizeInput<'a> {
@@ -233,20 +313,36 @@ pub(super) fn finalize_pending_fork_state(
233
313
  pub(crate) fn finalize_pending_fork_capture(
234
314
  agent: &mut serde_json::Map<String, serde_json::Value>,
235
315
  captured: &crate::provider::CapturedSession,
236
- ) -> bool {
316
+ ) -> Option<ContextForkFinalized> {
237
317
  let Some(session_id) = captured.session_id.as_ref() else {
238
- return false;
318
+ return None;
239
319
  };
240
320
  let Some(rollout_path) = captured.rollout_path.as_ref() else {
241
- return false;
321
+ return None;
242
322
  };
243
323
  if agent
244
324
  .get("fork_source_session_id")
245
325
  .and_then(serde_json::Value::as_str)
246
326
  == Some(session_id.as_str())
247
327
  {
248
- return false;
328
+ return None;
249
329
  }
330
+ let finalized = ContextForkFinalized::from_captured(
331
+ agent
332
+ .get("forked_from")
333
+ .and_then(serde_json::Value::as_str)
334
+ .unwrap_or_default(),
335
+ agent
336
+ .get("agent_id")
337
+ .and_then(serde_json::Value::as_str)
338
+ .or_else(|| {
339
+ agent
340
+ .get("pending_target_agent")
341
+ .and_then(serde_json::Value::as_str)
342
+ })
343
+ .unwrap_or_default(),
344
+ captured,
345
+ )?;
250
346
  agent.insert(
251
347
  "session_id".to_string(),
252
348
  serde_json::json!(session_id.as_str()),
@@ -269,11 +365,55 @@ pub(crate) fn finalize_pending_fork_capture(
269
365
  );
270
366
  agent.remove("_pending_session_id");
271
367
  agent.remove("attribution_ambiguous");
272
- agent.remove("fork_source_session_id");
273
368
  agent.remove("pending_target_agent");
274
369
  agent.remove("pending_grace_secs");
275
370
  agent.insert("capture_state".to_string(), serde_json::json!("captured"));
276
- true
371
+ Some(finalized)
372
+ }
373
+
374
+ #[cfg(test)]
375
+ mod tests {
376
+ use super::*;
377
+
378
+ #[test]
379
+ fn context_fork_finalized_writes_catalog_event_and_canonical_fields() {
380
+ let workspace = std::env::temp_dir().join(format!(
381
+ "team-agent-context-fork-audit-{}",
382
+ std::process::id()
383
+ ));
384
+ let _ = std::fs::remove_dir_all(&workspace);
385
+ std::fs::create_dir_all(&workspace).expect("create audit workspace");
386
+ let event_log = crate::event_log::EventLog::new(&workspace);
387
+ ContextForkFinalized::new(
388
+ "source",
389
+ "target",
390
+ "target-session",
391
+ Path::new("/tmp/target.jsonl"),
392
+ "context_fork_verified",
393
+ "high",
394
+ )
395
+ .write_audit(&event_log)
396
+ .expect("write context fork audit");
397
+
398
+ let events = event_log.tail(0).expect("read context fork audit");
399
+ let event = events.last().expect("context fork audit event");
400
+ assert_eq!(
401
+ event.get("event").and_then(serde_json::Value::as_str),
402
+ Some(crate::lifecycle::types::event_names::CONTEXT_FORK)
403
+ );
404
+ assert_eq!(
405
+ event
406
+ .get("source_agent_id")
407
+ .and_then(serde_json::Value::as_str),
408
+ Some("source")
409
+ );
410
+ assert_eq!(
411
+ event.get("agent_id").and_then(serde_json::Value::as_str),
412
+ Some("target")
413
+ );
414
+ assert!(event.get("prompt").is_none());
415
+ let _ = std::fs::remove_dir_all(workspace);
416
+ }
277
417
  }
278
418
 
279
419
  pub(super) fn verify_fork_registration(
@@ -354,6 +354,11 @@ pub(super) fn upsert_forked_agent_state(
354
354
  "forked_from".to_string(),
355
355
  serde_json::json!(source_agent_id.as_str()),
356
356
  );
357
+ entry.insert(
358
+ "fork_source_session_id".to_string(),
359
+ serde_json::json!(context_proof.source_session_id.as_str()),
360
+ );
361
+ entry.insert("capture_state".to_string(), serde_json::json!("captured"));
357
362
  entry.insert(
358
363
  "dynamic_role_file".to_string(),
359
364
  serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
@@ -295,8 +295,8 @@ mod fork_state;
295
295
  pub(super) use fork_state::*;
296
296
 
297
297
  mod fork_finalize;
298
- pub(crate) use fork_finalize::finalize_pending_fork_capture;
299
298
  pub(super) use fork_finalize::*;
299
+ pub(crate) use fork_finalize::{finalize_pending_fork_capture, ContextForkFinalized};
300
300
 
301
301
  mod role_source;
302
302
  pub(super) use role_source::*;
@@ -1178,7 +1178,29 @@ pub(super) fn fork_ws(alpha_role: &str) -> PathBuf {
1178
1178
  std::process::id(),
1179
1179
  n
1180
1180
  ));
1181
- std::fs::write(&rollout, b"{}\n").expect("seed fork source rollout");
1181
+ std::fs::write(
1182
+ &rollout,
1183
+ format!(
1184
+ "{}\n{}\n",
1185
+ json!({
1186
+ "type": "session_meta",
1187
+ "payload": {
1188
+ "id": "sess-a",
1189
+ "cwd": ws,
1190
+ }
1191
+ }),
1192
+ json!({
1193
+ "type": "response_item",
1194
+ "payload": {
1195
+ "content": [{
1196
+ "type": "input_text",
1197
+ "text": "You are Team Agent worker `alpha` with role `fixture`."
1198
+ }]
1199
+ }
1200
+ })
1201
+ ),
1202
+ )
1203
+ .expect("seed identity-complete fork source rollout");
1182
1204
  crate::state::persist::save_runtime_state(
1183
1205
  &ws,
1184
1206
  &json!({
@@ -1669,6 +1691,18 @@ fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
1669
1691
  fn lanea_fork_report_session_id_is_not_pane_id() {
1670
1692
  let _home = LaneHomeGuard::enter("fork-report");
1671
1693
  let ws = fork_ws(DELEG_ROLE_ALPHA); // codex+subscription -> native fork supported -> full success path
1694
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
1695
+ let source = PathBuf::from(state["agents"]["alpha"]["rollout_path"].as_str().unwrap());
1696
+ let codex_root = std::env::var_os("HOME")
1697
+ .map(PathBuf::from)
1698
+ .unwrap()
1699
+ .join(".codex/sessions/lane-fixture");
1700
+ std::fs::create_dir_all(&codex_root).unwrap();
1701
+ let discoverable_source = codex_root.join("rollout-sess-a.jsonl");
1702
+ std::fs::copy(source, &discoverable_source).unwrap();
1703
+ state["agents"]["alpha"]["rollout_path"] =
1704
+ json!(discoverable_source.to_string_lossy().to_string());
1705
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1672
1706
  let tx = LaneTransport::new("team-laneateam", &[]);
1673
1707
  let report =
1674
1708
  fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), None, false, None, &tx)
@@ -36,6 +36,7 @@ pub mod event_names {
36
36
  pub const ADD_FAILED: &str = "lifecycle.add_failed";
37
37
  pub const REMOVE_STEP_COMPLETED: &str = "lifecycle.remove_step_completed";
38
38
  pub const REMOVE_ROLLED_BACK: &str = "lifecycle.remove_rolled_back";
39
+ pub const CONTEXT_FORK: &str = "context_fork";
39
40
  // restart 决策事件(Route B audit 契约必发)。
40
41
  pub const RESTART_RESUME_DECISION: &str = "restart.resume_decision";
41
42
  pub const RESTART_ATOMIC_REFUSAL: &str = "restart.atomic_refusal";
@@ -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: normalize_test_status(obj.get("status").and_then(Value::as_str)),
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(
@@ -12,6 +12,11 @@ use super::{AuthMode, Provider};
12
12
 
13
13
  pub use crate::provider::session_scan::{CaptureSessionContext, CapturedSessionCandidate};
14
14
 
15
+ pub trait ForkBackingMaterialization {
16
+ fn path(&self) -> &Path;
17
+ fn handoff(&mut self);
18
+ }
19
+
15
20
  // ===========================================================================
16
21
  // TRAIT: ProviderAdapter (method SIGNATURES only — 无 body)
17
22
  // ===========================================================================
@@ -187,6 +192,24 @@ pub trait ProviderAdapter {
187
192
  .map(CommandPlan::argv_only)
188
193
  }
189
194
 
195
+ fn materialize_fork_backing(
196
+ &self,
197
+ source_path: &Path,
198
+ source_session_id: &SessionId,
199
+ source_agent_id: &str,
200
+ target_agent_id: &str,
201
+ plan: &mut CommandPlan,
202
+ ) -> Result<Option<Box<dyn ForkBackingMaterialization>>, ProviderError> {
203
+ let _ = (
204
+ source_path,
205
+ source_session_id,
206
+ source_agent_id,
207
+ target_agent_id,
208
+ plan,
209
+ );
210
+ Ok(None)
211
+ }
212
+
190
213
  /// 计算本 provider 该用的 MCP server 配置(`adapter.py` mcp_config;claude
191
214
  /// compatible_api 走 `ensure_compatible_claude_mcp_config`)。
192
215
  fn mcp_config(&self, auth_mode: AuthMode) -> Result<McpConfig, ProviderError>;
@@ -549,6 +572,29 @@ impl ProviderAdapter for BasicProviderAdapter {
549
572
  }
550
573
  }
551
574
 
575
+ fn materialize_fork_backing(
576
+ &self,
577
+ source_path: &Path,
578
+ source_session_id: &SessionId,
579
+ source_agent_id: &str,
580
+ target_agent_id: &str,
581
+ plan: &mut CommandPlan,
582
+ ) -> Result<Option<Box<dyn ForkBackingMaterialization>>, ProviderError> {
583
+ match self.provider {
584
+ Provider::Codex => crate::provider::session::materialize_codex_fork(
585
+ source_path,
586
+ source_session_id,
587
+ source_agent_id,
588
+ target_agent_id,
589
+ plan,
590
+ )
591
+ .map(|materialized| {
592
+ Some(Box::new(materialized) as Box<dyn ForkBackingMaterialization>)
593
+ }),
594
+ _ => Ok(None),
595
+ }
596
+ }
597
+
552
598
  fn capture_session_id(
553
599
  &self,
554
600
  agent_id: &str,
@@ -1198,3 +1244,14 @@ pub(crate) fn next_session_token() -> String {
1198
1244
  bytes[15],
1199
1245
  )
1200
1246
  }
1247
+
1248
+ #[cfg(test)]
1249
+ mod fork_materialization_source_guard {
1250
+ #[test]
1251
+ fn lifecycle_uses_provider_neutral_fork_materialization_dispatch() {
1252
+ let source = include_str!("../lifecycle/launch/fork_agent.rs");
1253
+ assert!(source.contains(".materialize_fork_backing("));
1254
+ assert!(!source.contains("materialize_codex_fork"));
1255
+ assert!(!source.contains("codex_fork"));
1256
+ }
1257
+ }
@@ -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.replace(source_session_id.as_str(), target_session_id.as_str());
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).unwrap();
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: if matches!(provider, Provider::Codex) {
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
- ) -> bool {
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 false;
1175
+ return None;
1163
1176
  };
1164
1177
  let Some(rollout_path) = captured.rollout_path.as_ref() else {
1165
- return false;
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
- true
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 return true for valid captured");
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 \