@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +6 -0
- package/crates/team-agent/src/cli/status_port.rs +70 -2
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
- package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
- package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
- package/crates/team-agent/src/provider/session/capture.rs +113 -22
- package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
- package/crates/team-agent/src/tmux_backend.rs +5 -16
- package/crates/team-agent/src/transport/tests/wire.rs +6 -0
- package/crates/team-agent/src/transport.rs +8 -2
- package/package.json +4 -4
|
@@ -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
|
|
877
|
-
"gate must run
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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::*;
|