@team-agent/installer 0.5.45 → 0.5.46

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.
@@ -428,6 +428,18 @@ fn restart_with_selected_team_and_transport(
428
428
  let live_worker_session_deferred_teardown =
429
429
  session_live_or_default(transport, &session_name, false)
430
430
  && !collect_live_agents_from_state(&state).is_empty();
431
+ let same_role_cohort_targets =
432
+ same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
433
+ let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
434
+ transport
435
+ .list_targets()
436
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?
437
+ .into_iter()
438
+ .map(|pane| pane.pane_id.as_str().to_string())
439
+ .collect::<std::collections::BTreeSet<_>>()
440
+ } else {
441
+ std::collections::BTreeSet::new()
442
+ };
431
443
  if session_live_or_default(transport, &session_name, false)
432
444
  && !live_worker_session_deferred_teardown
433
445
  {
@@ -779,6 +791,46 @@ fn restart_with_selected_team_and_transport(
779
791
  );
780
792
  }
781
793
  // END_B5_RESTART_ISOLATION_LOOP
794
+ if live_worker_session_deferred_teardown {
795
+ let successful_agent_ids = successful_agents
796
+ .iter()
797
+ .map(|agent| agent.agent_id.as_str())
798
+ .collect::<std::collections::BTreeSet<_>>();
799
+ let old_successful_targets = same_role_cohort_targets
800
+ .iter()
801
+ .filter(|target| successful_agent_ids.contains(target.agent_id.as_str()))
802
+ .cloned()
803
+ .collect::<Vec<_>>();
804
+ let successful_targets =
805
+ same_role_cohort_targets_from_decisions(&state, spec_workspace, &successful_agents);
806
+ let actual_spawn_panes = actual_successful_restart_spawn_panes(
807
+ transport,
808
+ &session_name,
809
+ &successful_targets,
810
+ &pre_spawn_pane_ids,
811
+ )?;
812
+ if let Some(error) =
813
+ actual_restart_spawn_binding_error(&successful_targets, &actual_spawn_panes)
814
+ {
815
+ cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
816
+ return Err(LifecycleError::RequirementUnmet(error));
817
+ }
818
+ if let Err(error) =
819
+ retire_expected_same_role_cohorts(transport, "restart", &old_successful_targets)
820
+ {
821
+ cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
822
+ return Err(LifecycleError::RequirementUnmet(error));
823
+ }
824
+ if let Some(error) = same_role_cohort_exactly_one_error(
825
+ transport,
826
+ &session_name,
827
+ "restart",
828
+ &successful_targets,
829
+ ) {
830
+ cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
831
+ return Err(LifecycleError::RequirementUnmet(error));
832
+ }
833
+ }
782
834
  let mut topology_authority_agent_ids = successful_agents
783
835
  .iter()
784
836
  .map(|agent| agent.agent_id.as_str().to_string())
@@ -2090,6 +2142,122 @@ fn mark_fake_harness_agent_respawned(
2090
2142
  agent.insert("owner_team_id".to_string(), serde_json::json!(team_key));
2091
2143
  }
2092
2144
 
2145
+ fn same_role_cohort_targets_from_decisions(
2146
+ state: &serde_json::Value,
2147
+ spec_workspace: &Path,
2148
+ agents: &[RestartedAgent],
2149
+ ) -> Vec<SameRoleCohortTarget> {
2150
+ let mut targets = Vec::new();
2151
+ for restarted in agents {
2152
+ let agent_id = &restarted.agent_id;
2153
+ let raw_agent = state
2154
+ .get("agents")
2155
+ .and_then(|agents| agents.get(agent_id.as_str()));
2156
+ let effective_agent = raw_agent.map(|agent| {
2157
+ rehydrate_agent_command_context_from_spec(spec_workspace, agent_id, agent)
2158
+ });
2159
+ if effective_agent
2160
+ .as_ref()
2161
+ .and_then(|agent| agent.get("provider"))
2162
+ .and_then(serde_json::Value::as_str)
2163
+ .is_some_and(|provider| provider.eq_ignore_ascii_case("fake"))
2164
+ {
2165
+ continue;
2166
+ }
2167
+ let window = raw_agent
2168
+ .and_then(|agent| agent.get("window"))
2169
+ .and_then(serde_json::Value::as_str)
2170
+ .filter(|value| !value.is_empty())
2171
+ .unwrap_or_else(|| agent_id.as_str());
2172
+ if is_per_agent_cohort_window(window, agent_id) {
2173
+ let expected_pane_id = raw_agent
2174
+ .and_then(|agent| agent.get("pane_id"))
2175
+ .and_then(serde_json::Value::as_str)
2176
+ .filter(|value| !value.is_empty());
2177
+ targets.push(
2178
+ SameRoleCohortTarget::new(agent_id, window).with_expected_pane_id(expected_pane_id),
2179
+ );
2180
+ }
2181
+ }
2182
+ targets
2183
+ }
2184
+
2185
+ fn actual_successful_restart_spawn_panes(
2186
+ transport: &dyn crate::transport::Transport,
2187
+ session_name: &SessionName,
2188
+ targets: &[SameRoleCohortTarget],
2189
+ pre_spawn_pane_ids: &std::collections::BTreeSet<String>,
2190
+ ) -> Result<Vec<crate::transport::PaneInfo>, LifecycleError> {
2191
+ let windows = targets
2192
+ .iter()
2193
+ .map(|target| target.window.as_str())
2194
+ .collect::<std::collections::BTreeSet<_>>();
2195
+ let panes = transport
2196
+ .list_targets()
2197
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?
2198
+ .into_iter()
2199
+ .filter(|pane| !pre_spawn_pane_ids.contains(pane.pane_id.as_str()))
2200
+ .filter(|pane| pane.session.as_str() == session_name.as_str())
2201
+ .filter(|pane| {
2202
+ pane.window_name
2203
+ .as_ref()
2204
+ .is_some_and(|window| windows.contains(window.as_str()))
2205
+ })
2206
+ .filter(|pane| {
2207
+ transport
2208
+ .liveness(&pane.pane_id)
2209
+ .is_ok_and(|state| state == crate::transport::PaneLiveness::Live)
2210
+ })
2211
+ .collect();
2212
+ Ok(panes)
2213
+ }
2214
+
2215
+ fn actual_restart_spawn_binding_error(
2216
+ targets: &[SameRoleCohortTarget],
2217
+ actual_spawn_panes: &[crate::transport::PaneInfo],
2218
+ ) -> Option<String> {
2219
+ let mut mismatches = Vec::new();
2220
+ for target in targets {
2221
+ let observed = actual_spawn_panes
2222
+ .iter()
2223
+ .filter(|pane| {
2224
+ pane.window_name
2225
+ .as_ref()
2226
+ .is_some_and(|window| window.as_str() == target.window)
2227
+ })
2228
+ .map(|pane| pane.pane_id.as_str().to_string())
2229
+ .collect::<Vec<_>>();
2230
+ if observed.len() != 1
2231
+ || target.expected_pane_id.as_deref() != observed.first().map(String::as_str)
2232
+ {
2233
+ mismatches.push(format!(
2234
+ "{}:window={}:expected_pane={}:actual_spawn_panes=[{}]",
2235
+ target.agent_id,
2236
+ target.window,
2237
+ target.expected_pane_id.as_deref().unwrap_or("<missing>"),
2238
+ observed.join(",")
2239
+ ));
2240
+ }
2241
+ }
2242
+ if mismatches.is_empty() {
2243
+ None
2244
+ } else {
2245
+ Some(format!(
2246
+ "restart refused: spawn identity/binding mismatch; {}",
2247
+ mismatches.join("; ")
2248
+ ))
2249
+ }
2250
+ }
2251
+
2252
+ fn cleanup_actual_restart_spawns(
2253
+ transport: &dyn crate::transport::Transport,
2254
+ actual_spawn_panes: &[crate::transport::PaneInfo],
2255
+ ) {
2256
+ for pane in actual_spawn_panes {
2257
+ let _ = transport.kill_pane(&pane.pane_id);
2258
+ }
2259
+ }
2260
+
2093
2261
  fn write_fake_harness_spawn_argv_event(
2094
2262
  workspace: &Path,
2095
2263
  decision: &RestartedAgent,
@@ -402,6 +402,11 @@ case "$*" in
402
402
  ;;
403
403
  esac
404
404
  case "$*" in
405
+ *"new-window "*|*"new-session "*)
406
+ : > "$spawned_marker"
407
+ printf '%%9288\n'
408
+ exit 0
409
+ ;;
405
410
  *"display-message -p -t $pane #{pane_id}"*)
406
411
  if [ -f "$killed_marker" ]; then
407
412
  echo "can't find pane: $pane" >&2
@@ -410,11 +415,6 @@ case "$*" in
410
415
  printf '%s\n' "$pane"
411
416
  exit 0
412
417
  ;;
413
- *"display-message -p -t $session:alpha #{pane_id}"*)
414
- : > "$spawned_marker"
415
- printf '%%9288\n'
416
- exit 0
417
- ;;
418
418
  *"list-windows -t $session -F #{window_name}"*)
419
419
  exit 0
420
420
  ;;
@@ -873,8 +873,8 @@ fn reset_agent_stop_not_proven_grep_guard_hard_gate_wired() {
873
873
  "reset_agent_at_paths must emit reset_agent.stop_not_proven via the writer"
874
874
  );
875
875
  assert!(
876
- contents.contains("if !agent_is_paused && !stop.stopped"),
877
- "gate must run only when stop.stopped=false (the dangerous case)"
876
+ contents.contains("if !agent_is_paused {"),
877
+ "gate must run after stop for every non-paused reset, including stop.stopped=true residue"
878
878
  );
879
879
  assert!(
880
880
  contents.contains("list_same_role_panes") && contents.contains("is_per_agent_window"),
@@ -744,7 +744,7 @@ impl Transport for BRealTransport {
744
744
  if matches!(self.mode, BRealMode::Misowned) {
745
745
  targets.push(pane_info("team-phaseb", "w2", "%5", 502));
746
746
  }
747
- if matches!(self.mode, BRealMode::Owned) {
747
+ if matches!(self.mode, BRealMode::Owned) && !self.spawns.lock().unwrap().is_empty() {
748
748
  targets.push(pane_info("team-phaseb", "w1", "%10", 501));
749
749
  }
750
750
  Ok(targets)
@@ -553,6 +553,9 @@ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> V
553
553
  let sorted = map.into_iter().collect::<BTreeMap<_, _>>();
554
554
  let mut out = Map::new();
555
555
  for (child_key, child) in sorted {
556
+ if matches!(child_key.as_str(), "model_source" | "provider_source") {
557
+ continue;
558
+ }
556
559
  out.insert(
557
560
  child_key.clone(),
558
561
  normalize_value(child, ctx, Some(child_key.as_str())),
@@ -632,6 +632,9 @@ pub enum ResetAgentOutcome {
632
632
  discarded_session_id: Option<SessionId>,
633
633
  session_id: Option<SessionId>,
634
634
  new_session_id: Option<SessionId>,
635
+ capture_state: String,
636
+ reset_proof: String,
637
+ weak_reset_warning: Option<String>,
635
638
  },
636
639
  /// 未传 discard_session → 拒绝(不丢上下文的误用保护)。
637
640
  Refused { reason: ResetRefusal },
@@ -67,6 +67,9 @@ pub(crate) fn reset_agent(
67
67
  discarded_session_id,
68
68
  session_id,
69
69
  new_session_id,
70
+ capture_state,
71
+ reset_proof,
72
+ weak_reset_warning,
70
73
  } => Ok(ToolOk {
71
74
  fields: object_fields(serde_json::json!({
72
75
  "ok": true,
@@ -78,6 +81,13 @@ pub(crate) fn reset_agent(
78
81
  "discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
79
82
  "session_id": session_id.as_ref().map(|id| id.as_str()),
80
83
  "new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
84
+ "capture_state": match capture_state.as_str() {
85
+ "captured" => "captured",
86
+ "attribution_ambiguous" => "attribution_ambiguous",
87
+ _ => "transcript_missing",
88
+ },
89
+ "reset_proof": if reset_proof == "weak" { "weak" } else { "strong" },
90
+ "weak_reset_warning": weak_reset_warning,
81
91
  })),
82
92
  }),
83
93
  ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
@@ -8,6 +8,7 @@ use crate::provider::{
8
8
  CaptureSessionContext, CapturedSession, CapturedSessionCandidate, Provider, ProviderAdapter,
9
9
  ProviderError, SessionId,
10
10
  };
11
+ use crate::state::identity_keys::SessionAttributionKey;
11
12
 
12
13
  pub const SESSION_CAPTURE_CONVERGENCE_DEADLINE_MS: u64 = 12_000;
13
14
  pub const SESSION_CAPTURE_CONVERGENCE_POLL_MS: u64 = 250;
@@ -638,6 +639,7 @@ pub fn incomplete_interacted_resumable_agent_ids(state: &Value) -> Vec<String> {
638
639
  struct PendingSessionCapture {
639
640
  agent_id: String,
640
641
  provider: Provider,
642
+ team_key: String,
641
643
  context: CaptureSessionContext,
642
644
  }
643
645
 
@@ -673,6 +675,13 @@ where
673
675
  Some(PendingSessionCapture {
674
676
  agent_id: agent_id.to_string(),
675
677
  provider,
678
+ team_key: agent
679
+ .get("owner_team_id")
680
+ .or_else(|| agent.get("team_key"))
681
+ .and_then(Value::as_str)
682
+ .filter(|s| !s.is_empty())
683
+ .unwrap_or("current")
684
+ .to_string(),
676
685
  context: CaptureSessionContext {
677
686
  agent_id: agent_id.to_string(),
678
687
  spawn_cwd: PathBuf::from(spawn_cwd),
@@ -883,14 +892,14 @@ fn allocate_session_candidates(
883
892
  .as_ref()
884
893
  .is_some_and(|sid| sid.as_str() == expected.as_str())
885
894
  })
886
- .filter(|candidate| !candidate_keys_collide(candidate, claimed))
895
+ .filter(|candidate| !candidate_keys_collide(item, candidate, claimed))
887
896
  .collect();
888
897
  // Uniqueness requirement: only assign when the expected id maps to
889
898
  // exactly one available candidate. Multiple matches or a colliding
890
899
  // single match leave the agent for the ambiguity path below.
891
900
  if exact_matches.len() == 1 {
892
901
  let candidate = exact_matches[0].clone();
893
- claimed.extend(captured_provider_session_keys(&candidate.captured));
902
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
894
903
  assignments.insert(item.agent_id.clone(), candidate);
895
904
  }
896
905
  }
@@ -903,8 +912,9 @@ fn allocate_session_candidates(
903
912
  candidates_by_agent.get(&item.agent_id),
904
913
  claimed,
905
914
  CandidateMatchKind::PositiveAgentId,
915
+ item,
906
916
  ) {
907
- claimed.extend(captured_provider_session_keys(&candidate.captured));
917
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
908
918
  assignments.insert(item.agent_id.clone(), candidate);
909
919
  }
910
920
  }
@@ -916,8 +926,9 @@ fn allocate_session_candidates(
916
926
  candidates_by_agent.get(&item.agent_id),
917
927
  claimed,
918
928
  CandidateMatchKind::PathAgentId,
929
+ item,
919
930
  ) {
920
- claimed.extend(captured_provider_session_keys(&candidate.captured));
931
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
921
932
  assignments.insert(item.agent_id.clone(), candidate);
922
933
  }
923
934
  }
@@ -953,9 +964,10 @@ fn allocate_session_candidates(
953
964
  candidates_by_agent.get(&item.agent_id),
954
965
  claimed,
955
966
  CandidateMatchKind::Any,
967
+ item,
956
968
  ) {
957
969
  Some(candidate) => {
958
- claimed.extend(captured_provider_session_keys(&candidate.captured));
970
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
959
971
  assignments.insert(item.agent_id.clone(), candidate);
960
972
  }
961
973
  None => {
@@ -995,11 +1007,14 @@ fn allocate_global_one_to_one(
995
1007
  let Some(agent_candidates) = candidates_by_agent.get(agent_id) else {
996
1008
  return;
997
1009
  };
1010
+ let Some(owner) = pending.iter().find(|item| item.agent_id == *agent_id) else {
1011
+ continue;
1012
+ };
998
1013
  for candidate in agent_candidates {
999
- if candidate_keys_collide(candidate, claimed) {
1014
+ if candidate_keys_collide(owner, candidate, claimed) {
1000
1015
  continue;
1001
1016
  }
1002
- let key = candidate_key(candidate);
1017
+ let key = candidate_key(owner, candidate);
1003
1018
  if key.is_empty() {
1004
1019
  continue;
1005
1020
  }
@@ -1010,7 +1025,9 @@ fn allocate_global_one_to_one(
1010
1025
  return;
1011
1026
  }
1012
1027
  for (agent_id, candidate) in remaining_agents.into_iter().zip(candidates.into_values()) {
1013
- claimed.extend(captured_provider_session_keys(&candidate.captured));
1028
+ if let Some(item) = pending.iter().find(|item| item.agent_id == agent_id) {
1029
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
1030
+ }
1014
1031
  assignments.insert(agent_id, candidate);
1015
1032
  }
1016
1033
  }
@@ -1035,6 +1052,7 @@ fn unique_available_candidate(
1035
1052
  candidates: Option<&Vec<CapturedSessionCandidate>>,
1036
1053
  claimed: &BTreeSet<String>,
1037
1054
  match_kind: CandidateMatchKind,
1055
+ owner: &PendingSessionCapture,
1038
1056
  ) -> Option<CapturedSessionCandidate> {
1039
1057
  let matches = candidates?
1040
1058
  .iter()
@@ -1043,7 +1061,7 @@ fn unique_available_candidate(
1043
1061
  CandidateMatchKind::PathAgentId => candidate.agent_path_match,
1044
1062
  CandidateMatchKind::Any => true,
1045
1063
  })
1046
- .filter(|candidate| !candidate_keys_collide(candidate, claimed))
1064
+ .filter(|candidate| !candidate_keys_collide(owner, candidate, claimed))
1047
1065
  .cloned()
1048
1066
  .collect::<Vec<_>>();
1049
1067
  if matches.len() == 1 {
@@ -1061,16 +1079,17 @@ enum CandidateMatchKind {
1061
1079
  }
1062
1080
 
1063
1081
  fn candidate_keys_collide(
1082
+ owner: &PendingSessionCapture,
1064
1083
  candidate: &CapturedSessionCandidate,
1065
1084
  claimed: &BTreeSet<String>,
1066
1085
  ) -> bool {
1067
- captured_provider_session_keys(&candidate.captured)
1086
+ captured_provider_session_keys(owner, &candidate.captured)
1068
1087
  .iter()
1069
1088
  .any(|key| claimed.contains(key))
1070
1089
  }
1071
1090
 
1072
- fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
1073
- captured_provider_session_keys(&candidate.captured)
1091
+ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandidate) -> String {
1092
+ captured_provider_session_keys(owner, &candidate.captured)
1074
1093
  .into_iter()
1075
1094
  .collect::<Vec<_>>()
1076
1095
  .join("|")
@@ -1130,12 +1149,13 @@ fn claimed_provider_session_keys(
1130
1149
  pending_ids: &BTreeSet<String>,
1131
1150
  ) -> BTreeSet<String> {
1132
1151
  let mut keys = BTreeSet::new();
1152
+ let team_key = crate::state::projection::team_state_key(state);
1133
1153
  // 1. Non-pending worker sessions (existing behaviour).
1134
1154
  for (agent_id, agent) in agents {
1135
1155
  if pending_ids.contains(agent_id) {
1136
1156
  continue;
1137
1157
  }
1138
- push_provider_session_keys(&mut keys, agent);
1158
+ push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
1139
1159
  }
1140
1160
  // 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
1141
1161
  // own provider transcript must never be attributed to a worker. Scan
@@ -1152,14 +1172,39 @@ fn claimed_provider_session_keys(
1152
1172
  keys
1153
1173
  }
1154
1174
 
1155
- fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1175
+ fn push_provider_session_keys(
1176
+ keys: &mut BTreeSet<String>,
1177
+ fallback_team_key: &str,
1178
+ fallback_agent_id: &str,
1179
+ value: &Value,
1180
+ ) {
1181
+ let provider = value
1182
+ .get("provider")
1183
+ .and_then(Value::as_str)
1184
+ .and_then(parse_provider)
1185
+ .unwrap_or(Provider::Fake);
1186
+ let team_key = value
1187
+ .get("owner_team_id")
1188
+ .or_else(|| value.get("team_key"))
1189
+ .and_then(Value::as_str)
1190
+ .filter(|s| !s.is_empty())
1191
+ .unwrap_or(fallback_team_key);
1192
+ let agent_id = value
1193
+ .get("agent_id")
1194
+ .and_then(Value::as_str)
1195
+ .filter(|s| !s.is_empty())
1196
+ .unwrap_or(fallback_agent_id);
1156
1197
  for field in ["session_id", "provider_session_id"] {
1157
1198
  if let Some(session_id) = value
1158
1199
  .get(field)
1159
1200
  .and_then(Value::as_str)
1160
1201
  .filter(|s| !s.is_empty())
1161
1202
  {
1162
- keys.insert(format!("session:{session_id}"));
1203
+ if let Some(key) = SessionAttributionKey::new(provider, team_key, agent_id, session_id)
1204
+ {
1205
+ keys.insert(session_attribution_key_string(&key));
1206
+ }
1207
+ keys.insert(global_session_attribution_key_string(session_id));
1163
1208
  }
1164
1209
  }
1165
1210
  for field in ["rollout_path", "transcript_path"] {
@@ -1168,7 +1213,10 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1168
1213
  .and_then(Value::as_str)
1169
1214
  .filter(|s| !s.is_empty())
1170
1215
  {
1171
- keys.insert(format!("rollout:{path}"));
1216
+ keys.insert(transcript_attribution_key_string(
1217
+ provider, team_key, agent_id, path,
1218
+ ));
1219
+ keys.insert(global_transcript_attribution_key_string(path));
1172
1220
  }
1173
1221
  }
1174
1222
  }
@@ -1176,25 +1224,68 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1176
1224
  fn push_leader_provider_session_keys(keys: &mut BTreeSet<String>, scope: &Value) {
1177
1225
  for anchor in ["leader_receiver", "team_owner"] {
1178
1226
  if let Some(node) = scope.get(anchor) {
1179
- push_provider_session_keys(keys, node);
1227
+ push_provider_session_keys(keys, "leader", "leader", node);
1180
1228
  }
1181
1229
  }
1182
1230
  }
1183
1231
 
1184
- fn captured_provider_session_keys(captured: &CapturedSession) -> BTreeSet<String> {
1232
+ fn captured_provider_session_keys(
1233
+ owner: &PendingSessionCapture,
1234
+ captured: &CapturedSession,
1235
+ ) -> BTreeSet<String> {
1185
1236
  let mut keys = BTreeSet::new();
1237
+ let team_key = owner.team_key.as_str();
1186
1238
  if let Some(session_id) = &captured.session_id {
1187
- keys.insert(format!("session:{}", session_id.as_str()));
1239
+ if let Some(key) = SessionAttributionKey::new(
1240
+ owner.provider,
1241
+ team_key,
1242
+ owner.agent_id.as_str(),
1243
+ session_id.as_str(),
1244
+ ) {
1245
+ keys.insert(session_attribution_key_string(&key));
1246
+ }
1247
+ keys.insert(global_session_attribution_key_string(session_id.as_str()));
1188
1248
  }
1189
1249
  if let Some(rollout_path) = &captured.rollout_path {
1190
- keys.insert(format!(
1191
- "rollout:{}",
1192
- rollout_path.as_path().to_string_lossy()
1250
+ let path = rollout_path.as_path().to_string_lossy();
1251
+ keys.insert(transcript_attribution_key_string(
1252
+ owner.provider,
1253
+ team_key,
1254
+ owner.agent_id.as_str(),
1255
+ &path,
1193
1256
  ));
1257
+ keys.insert(global_transcript_attribution_key_string(&path));
1194
1258
  }
1195
1259
  keys
1196
1260
  }
1197
1261
 
1262
+ fn session_attribution_key_string(key: &SessionAttributionKey) -> String {
1263
+ format!(
1264
+ "SessionAttributionKey(provider={:?},team={},agent={},session={})",
1265
+ key.provider(),
1266
+ key.team_key(),
1267
+ key.agent_id(),
1268
+ key.session_id()
1269
+ )
1270
+ }
1271
+
1272
+ fn transcript_attribution_key_string(
1273
+ provider: Provider,
1274
+ team_key: &str,
1275
+ agent_id: &str,
1276
+ path: &str,
1277
+ ) -> String {
1278
+ format!("TranscriptAttributionKey(provider={provider:?},team={team_key},agent={agent_id},path={path})")
1279
+ }
1280
+
1281
+ fn global_session_attribution_key_string(session_id: &str) -> String {
1282
+ format!("GlobalSessionAttributionKey(session={session_id})")
1283
+ }
1284
+
1285
+ fn global_transcript_attribution_key_string(path: &str) -> String {
1286
+ format!("GlobalTranscriptAttributionKey(path={path})")
1287
+ }
1288
+
1198
1289
  #[cfg(test)]
1199
1290
  pub(crate) mod test_support {
1200
1291
  use super::*;
@@ -14,10 +14,13 @@ pub(super) fn apply_spawned_at_filter(
14
14
  Some(p) => p.as_path(),
15
15
  None => return false,
16
16
  };
17
- std::fs::metadata(path)
18
- .and_then(|meta| meta.modified())
19
- .map(|mtime| mtime >= cutoff)
20
- .unwrap_or(false)
17
+ match codex_rollout_created_at(path) {
18
+ Some(created_at) => created_at >= cutoff,
19
+ None => std::fs::metadata(path)
20
+ .and_then(|meta| meta.modified())
21
+ .map(|mtime| mtime >= cutoff)
22
+ .unwrap_or(false),
23
+ }
21
24
  });
22
25
  }
23
26
 
@@ -27,6 +30,50 @@ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
27
30
  .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
28
31
  }
29
32
 
33
+ fn codex_rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
34
+ created_at_from_rollout_head(path).or_else(|| created_at_from_rollout_filename(path))
35
+ }
36
+
37
+ fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
38
+ let text = super::common::read_head_text(path, super::common::CAPTURE_HEAD_BYTES).ok()?;
39
+ super::common::parse_session_records(&text)
40
+ .iter()
41
+ .find_map(record_created_at)
42
+ .and_then(|raw| parse_spawned_at(&raw))
43
+ }
44
+
45
+ fn record_created_at(record: &serde_json::Value) -> Option<String> {
46
+ record
47
+ .get("created_at")
48
+ .and_then(serde_json::Value::as_str)
49
+ .or_else(|| {
50
+ record
51
+ .get("session_meta")
52
+ .and_then(|v| v.get("payload"))
53
+ .or_else(|| record.get("payload"))
54
+ .and_then(|v| v.get("created_at"))
55
+ .and_then(serde_json::Value::as_str)
56
+ })
57
+ .map(ToString::to_string)
58
+ }
59
+
60
+ fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time::SystemTime> {
61
+ let name = path.file_name()?.to_str()?;
62
+ let start = name.find("rollout-")? + "rollout-".len();
63
+ let stamp = name.get(start..start + 19)?;
64
+ if stamp.as_bytes().get(10).copied() != Some(b'T') {
65
+ return None;
66
+ }
67
+ let raw = format!(
68
+ "{}T{}:{}:{}+00:00",
69
+ &stamp[0..10],
70
+ &stamp[11..13],
71
+ &stamp[14..16],
72
+ &stamp[17..19]
73
+ );
74
+ parse_spawned_at(&raw)
75
+ }
76
+
30
77
  #[cfg(test)]
31
78
  mod tests {
32
79
  use super::*;