@team-agent/installer 0.5.28 → 0.5.31
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 +24 -4
- package/crates/team-agent/src/cli/diagnose.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +211 -59
- package/crates/team-agent/src/cli/mod.rs +45 -19
- package/crates/team-agent/src/cli/send.rs +19 -20
- package/crates/team-agent/src/cli/spec.rs +222 -0
- package/crates/team-agent/src/cli/status.rs +29 -0
- package/crates/team-agent/src/cli/status_port.rs +36 -4
- package/crates/team-agent/src/leader/lease.rs +166 -27
- package/crates/team-agent/src/lifecycle/launch.rs +6 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +94 -2
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +387 -0
- package/crates/team-agent/src/topology.rs +130 -5
- package/package.json +4 -4
|
@@ -440,21 +440,18 @@ pub fn claim_leader(
|
|
|
440
440
|
.unwrap_or_default();
|
|
441
441
|
let raw_state = crate::state::persist::load_runtime_state(workspace)?;
|
|
442
442
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
let mut targets = crate::transport_factory::tmux_workspace_transport(workspace)
|
|
446
|
-
.list_targets()
|
|
447
|
-
.unwrap_or_default();
|
|
448
|
-
targets.extend(
|
|
449
|
-
crate::transport_factory::tmux_default_transport()
|
|
450
|
-
.list_targets()
|
|
451
|
-
.unwrap_or_default(),
|
|
452
|
-
);
|
|
453
|
-
let caller_pane_info = targets
|
|
443
|
+
let targets = claim_leader_targets(workspace, &raw_state);
|
|
444
|
+
let caller_candidate = targets
|
|
454
445
|
.iter()
|
|
455
|
-
.
|
|
456
|
-
|
|
457
|
-
|
|
446
|
+
.filter(|target| target.info.pane_id.as_str() == caller)
|
|
447
|
+
.min_by_key(|target| target.source.priority());
|
|
448
|
+
let caller_pane_info = caller_candidate.map(|target| &target.info);
|
|
449
|
+
let caller_target = caller_candidate.and_then(|target| {
|
|
450
|
+
claim_target_from_pane_info(workspace, &target.info).map(|mut claim_target| {
|
|
451
|
+
claim_target.endpoint = target.endpoint.clone();
|
|
452
|
+
claim_target
|
|
453
|
+
})
|
|
454
|
+
});
|
|
458
455
|
let env_team = std::env::var("TEAM_AGENT_TEAM_ID")
|
|
459
456
|
.ok()
|
|
460
457
|
.filter(|team| !team.is_empty());
|
|
@@ -489,7 +486,7 @@ pub fn claim_leader(
|
|
|
489
486
|
} else {
|
|
490
487
|
raw_state
|
|
491
488
|
};
|
|
492
|
-
let liveness = AnyPaneLiveness::
|
|
489
|
+
let liveness = AnyPaneLiveness::from_claim_targets(&targets);
|
|
493
490
|
let result = claim_lease_no_incident_with_target(
|
|
494
491
|
workspace,
|
|
495
492
|
&mut state,
|
|
@@ -634,8 +631,23 @@ fn claim_lease_no_incident_with_target(
|
|
|
634
631
|
let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
|
|
635
632
|
if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
|
|
636
633
|
let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
|
|
634
|
+
let observed_endpoint = caller_target.and_then(|target| target.endpoint.as_deref());
|
|
635
|
+
let convergence_candidate = observed_endpoint.or(current_endpoint.as_deref());
|
|
636
|
+
let candidate_source = if observed_endpoint.is_some() {
|
|
637
|
+
Some("observed_target_endpoint")
|
|
638
|
+
} else if current_endpoint.is_some() {
|
|
639
|
+
Some("fallback_tmux_env")
|
|
640
|
+
} else {
|
|
641
|
+
None
|
|
642
|
+
};
|
|
637
643
|
let (mut topology_convergence, converged) =
|
|
638
|
-
apply_endpoint_convergence(
|
|
644
|
+
apply_endpoint_convergence(
|
|
645
|
+
state,
|
|
646
|
+
team_id,
|
|
647
|
+
convergence_candidate,
|
|
648
|
+
candidate_source,
|
|
649
|
+
pre_epoch,
|
|
650
|
+
);
|
|
639
651
|
if converged {
|
|
640
652
|
write_claim_state(workspace, state, scoped_team, team)?;
|
|
641
653
|
topology_convergence = verify_persisted_topology_convergence(
|
|
@@ -796,7 +808,8 @@ fn claim_lease_no_incident_with_target(
|
|
|
796
808
|
}
|
|
797
809
|
let next_epoch = OwnerEpoch(pre_epoch.0.saturating_add(1));
|
|
798
810
|
let provider = caller_target.map_or_else(|| prior_provider(state), |target| target.provider);
|
|
799
|
-
let
|
|
811
|
+
let observed_endpoint = caller_target.and_then(|target| target.endpoint.clone());
|
|
812
|
+
let mut receiver = make_receiver(
|
|
800
813
|
provider,
|
|
801
814
|
&non_empty_caller_pane,
|
|
802
815
|
&identity.leader_session_uuid,
|
|
@@ -804,10 +817,26 @@ fn claim_lease_no_incident_with_target(
|
|
|
804
817
|
Discovery::ClaimLeader,
|
|
805
818
|
caller_target.and_then(|target| target.pane_info.clone()),
|
|
806
819
|
);
|
|
820
|
+
if let Some(endpoint) = observed_endpoint.as_ref() {
|
|
821
|
+
receiver.tmux_socket = Some(endpoint.clone());
|
|
822
|
+
}
|
|
807
823
|
let owner = make_owner(provider, &non_empty_caller_pane, &identity, next_epoch);
|
|
808
824
|
write_binding_to_state(state, &receiver, &owner)?;
|
|
825
|
+
let candidate_source = if observed_endpoint.is_some() {
|
|
826
|
+
Some("observed_target_endpoint")
|
|
827
|
+
} else if receiver.tmux_socket.is_some() {
|
|
828
|
+
Some("fallback_tmux_env")
|
|
829
|
+
} else {
|
|
830
|
+
None
|
|
831
|
+
};
|
|
809
832
|
let (mut topology_convergence, converged) =
|
|
810
|
-
apply_endpoint_convergence(
|
|
833
|
+
apply_endpoint_convergence(
|
|
834
|
+
state,
|
|
835
|
+
team_id,
|
|
836
|
+
receiver.tmux_socket.as_deref(),
|
|
837
|
+
candidate_source,
|
|
838
|
+
next_epoch,
|
|
839
|
+
);
|
|
811
840
|
write_claim_state(workspace, state, scoped_team, team)?;
|
|
812
841
|
if converged {
|
|
813
842
|
topology_convergence = verify_persisted_topology_convergence(
|
|
@@ -884,13 +913,17 @@ fn apply_endpoint_convergence(
|
|
|
884
913
|
state: &mut Value,
|
|
885
914
|
team_id: &TeamKey,
|
|
886
915
|
candidate_endpoint: Option<&str>,
|
|
916
|
+
candidate_source: Option<&'static str>,
|
|
887
917
|
owner_epoch: OwnerEpoch,
|
|
888
918
|
) -> (Option<Value>, bool) {
|
|
889
|
-
let
|
|
919
|
+
let explicit_candidate = candidate_endpoint
|
|
890
920
|
.filter(|endpoint| !endpoint.is_empty())
|
|
891
|
-
.map(str::to_string)
|
|
892
|
-
|
|
893
|
-
|
|
921
|
+
.map(str::to_string);
|
|
922
|
+
let (candidate_endpoint, candidate_source) = if let Some(endpoint) = explicit_candidate {
|
|
923
|
+
(endpoint, candidate_source.unwrap_or("candidate"))
|
|
924
|
+
} else if let Some(endpoint) = state_tmux_socket_candidate(state, team_id.as_str()) {
|
|
925
|
+
(endpoint, "fallback_state")
|
|
926
|
+
} else {
|
|
894
927
|
return (None, false);
|
|
895
928
|
};
|
|
896
929
|
match crate::topology::endpoint_convergence_decision(
|
|
@@ -903,6 +936,7 @@ fn apply_endpoint_convergence(
|
|
|
903
936
|
Some(json!({
|
|
904
937
|
"status": "unknown",
|
|
905
938
|
"new_tmux_endpoint": candidate_endpoint,
|
|
939
|
+
"candidate_source": candidate_source,
|
|
906
940
|
"owner_epoch": owner_epoch.0,
|
|
907
941
|
})),
|
|
908
942
|
false,
|
|
@@ -910,14 +944,17 @@ fn apply_endpoint_convergence(
|
|
|
910
944
|
crate::topology::EndpointConvergenceDecision::RefuseLiveOldEndpoint {
|
|
911
945
|
old_endpoint,
|
|
912
946
|
new_endpoint,
|
|
947
|
+
reason,
|
|
913
948
|
} => (
|
|
914
949
|
Some(json!({
|
|
915
950
|
"status": "not_converged_old_endpoint_live",
|
|
916
951
|
"old_tmux_endpoint": old_endpoint,
|
|
917
952
|
"new_tmux_endpoint": new_endpoint,
|
|
953
|
+
"reason": reason,
|
|
954
|
+
"candidate_source": candidate_source,
|
|
918
955
|
"owner_epoch": owner_epoch.0,
|
|
919
956
|
"action": format!(
|
|
920
|
-
"old tmux endpoint {old_endpoint}
|
|
957
|
+
"old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
|
|
921
958
|
),
|
|
922
959
|
})),
|
|
923
960
|
false,
|
|
@@ -925,12 +962,14 @@ fn apply_endpoint_convergence(
|
|
|
925
962
|
crate::topology::EndpointConvergenceDecision::Converge {
|
|
926
963
|
old_endpoint,
|
|
927
964
|
new_endpoint,
|
|
965
|
+
reason,
|
|
928
966
|
} => {
|
|
929
967
|
let metadata = json!({
|
|
930
968
|
"status": "converged",
|
|
931
969
|
"old_tmux_endpoint": old_endpoint,
|
|
932
970
|
"new_tmux_endpoint": new_endpoint,
|
|
933
|
-
"reason":
|
|
971
|
+
"reason": reason,
|
|
972
|
+
"candidate_source": candidate_source,
|
|
934
973
|
"owner_epoch": owner_epoch.0,
|
|
935
974
|
});
|
|
936
975
|
write_endpoint_fields(state, team_id.as_str(), &new_endpoint);
|
|
@@ -1175,6 +1214,94 @@ struct LeaderClaimTarget {
|
|
|
1175
1214
|
leader_session_uuid: Option<crate::model::ids::LeaderSessionUuid>,
|
|
1176
1215
|
team_id: Option<String>,
|
|
1177
1216
|
pane_info: Option<PaneInfo>,
|
|
1217
|
+
endpoint: Option<String>,
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
#[derive(Clone)]
|
|
1221
|
+
struct ClaimLeaderTargetCandidate {
|
|
1222
|
+
info: PaneInfo,
|
|
1223
|
+
endpoint: Option<String>,
|
|
1224
|
+
source: ClaimLeaderTargetSource,
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
1228
|
+
enum ClaimLeaderTargetSource {
|
|
1229
|
+
StateRecorded,
|
|
1230
|
+
Workspace,
|
|
1231
|
+
CurrentTmux,
|
|
1232
|
+
Default,
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
impl ClaimLeaderTargetSource {
|
|
1236
|
+
fn priority(self) -> u8 {
|
|
1237
|
+
match self {
|
|
1238
|
+
Self::StateRecorded => 0,
|
|
1239
|
+
Self::Workspace => 1,
|
|
1240
|
+
Self::CurrentTmux => 2,
|
|
1241
|
+
Self::Default => 3,
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTargetCandidate> {
|
|
1247
|
+
let mut targets = Vec::new();
|
|
1248
|
+
if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
|
|
1249
|
+
let backend = tmux_backend_for_endpoint(&endpoint);
|
|
1250
|
+
let resolved_endpoint = backend.tmux_endpoint();
|
|
1251
|
+
targets.extend(
|
|
1252
|
+
backend
|
|
1253
|
+
.list_targets()
|
|
1254
|
+
.unwrap_or_default()
|
|
1255
|
+
.into_iter()
|
|
1256
|
+
.map(|info| ClaimLeaderTargetCandidate {
|
|
1257
|
+
info,
|
|
1258
|
+
endpoint: resolved_endpoint.clone(),
|
|
1259
|
+
source: ClaimLeaderTargetSource::CurrentTmux,
|
|
1260
|
+
}),
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
for endpoint in state_recorded_tmux_endpoints(state) {
|
|
1264
|
+
let backend = tmux_backend_for_endpoint(&endpoint);
|
|
1265
|
+
let resolved_endpoint = backend.tmux_endpoint();
|
|
1266
|
+
targets.extend(
|
|
1267
|
+
backend
|
|
1268
|
+
.list_targets()
|
|
1269
|
+
.unwrap_or_default()
|
|
1270
|
+
.into_iter()
|
|
1271
|
+
.map(|info| ClaimLeaderTargetCandidate {
|
|
1272
|
+
info,
|
|
1273
|
+
endpoint: resolved_endpoint.clone(),
|
|
1274
|
+
source: ClaimLeaderTargetSource::StateRecorded,
|
|
1275
|
+
}),
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
let workspace_backend = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
1279
|
+
let workspace_endpoint = workspace_backend.tmux_endpoint();
|
|
1280
|
+
targets.extend(
|
|
1281
|
+
workspace_backend
|
|
1282
|
+
.list_targets()
|
|
1283
|
+
.unwrap_or_default()
|
|
1284
|
+
.into_iter()
|
|
1285
|
+
.map(|info| ClaimLeaderTargetCandidate {
|
|
1286
|
+
info,
|
|
1287
|
+
endpoint: workspace_endpoint.clone(),
|
|
1288
|
+
source: ClaimLeaderTargetSource::Workspace,
|
|
1289
|
+
}),
|
|
1290
|
+
);
|
|
1291
|
+
let default_backend = crate::transport_factory::tmux_default_transport();
|
|
1292
|
+
let default_endpoint = default_backend.tmux_endpoint();
|
|
1293
|
+
targets.extend(
|
|
1294
|
+
default_backend
|
|
1295
|
+
.list_targets()
|
|
1296
|
+
.unwrap_or_default()
|
|
1297
|
+
.into_iter()
|
|
1298
|
+
.map(|info| ClaimLeaderTargetCandidate {
|
|
1299
|
+
info,
|
|
1300
|
+
endpoint: default_endpoint.clone(),
|
|
1301
|
+
source: ClaimLeaderTargetSource::Default,
|
|
1302
|
+
}),
|
|
1303
|
+
);
|
|
1304
|
+
targets
|
|
1178
1305
|
}
|
|
1179
1306
|
|
|
1180
1307
|
fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<LeaderClaimTarget> {
|
|
@@ -1191,6 +1318,7 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
|
|
|
1191
1318
|
leader_session_uuid: target_leader_session_uuid(target),
|
|
1192
1319
|
team_id: target.leader_env.get("TEAM_AGENT_TEAM_ID").filter(|raw| !raw.is_empty()).cloned(),
|
|
1193
1320
|
pane_info: Some(target.clone()),
|
|
1321
|
+
endpoint: None,
|
|
1194
1322
|
})
|
|
1195
1323
|
}
|
|
1196
1324
|
|
|
@@ -1290,11 +1418,11 @@ struct AnyPaneLiveness {
|
|
|
1290
1418
|
}
|
|
1291
1419
|
|
|
1292
1420
|
impl AnyPaneLiveness {
|
|
1293
|
-
fn
|
|
1421
|
+
fn from_claim_targets(targets: &[ClaimLeaderTargetCandidate]) -> Self {
|
|
1294
1422
|
Self {
|
|
1295
1423
|
live_panes: targets
|
|
1296
1424
|
.iter()
|
|
1297
|
-
.map(|target| target.pane_id.as_str().to_string())
|
|
1425
|
+
.map(|target| target.info.pane_id.as_str().to_string())
|
|
1298
1426
|
.collect(),
|
|
1299
1427
|
}
|
|
1300
1428
|
}
|
|
@@ -1677,10 +1805,16 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
|
|
|
1677
1805
|
.map(|_| crate::state::projection::team_state_key(&existing));
|
|
1678
1806
|
let existing_active_key = existing.get("active_team_key").and_then(Value::as_str);
|
|
1679
1807
|
let updates_active_team = existing_active_key == Some(target_key);
|
|
1808
|
+
let writes_endpoint_convergence = state
|
|
1809
|
+
.get("topology_convergence")
|
|
1810
|
+
.and_then(|marker| marker.get("status"))
|
|
1811
|
+
.and_then(Value::as_str)
|
|
1812
|
+
== Some("converged");
|
|
1680
1813
|
let mut merged = if existing_primary_key
|
|
1681
1814
|
.as_deref()
|
|
1682
1815
|
.is_none_or(|key| key == target_key)
|
|
1683
1816
|
|| updates_active_team
|
|
1817
|
+
|| writes_endpoint_convergence
|
|
1684
1818
|
{
|
|
1685
1819
|
value_object(state)
|
|
1686
1820
|
} else {
|
|
@@ -1697,7 +1831,12 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
|
|
|
1697
1831
|
// / coordinator tick / promote sibling. `had_existing_teams` /
|
|
1698
1832
|
// primary/active key computations are retained as dead writes only
|
|
1699
1833
|
// via _ binding — they no longer gate any owner promote.
|
|
1700
|
-
let _ = (
|
|
1834
|
+
let _ = (
|
|
1835
|
+
had_existing_teams,
|
|
1836
|
+
existing_primary_key,
|
|
1837
|
+
existing_active_key,
|
|
1838
|
+
writes_endpoint_convergence,
|
|
1839
|
+
);
|
|
1701
1840
|
merged.insert("teams".to_string(), Value::Object(teams));
|
|
1702
1841
|
crate::state::persist::save_runtime_state(workspace, &Value::Object(merged))?;
|
|
1703
1842
|
Ok(())
|
|
@@ -4130,7 +4130,12 @@ fn upsert_agent_state_from_role(
|
|
|
4130
4130
|
|
|
4131
4131
|
/// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
|
|
4132
4132
|
/// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
|
|
4133
|
-
|
|
4133
|
+
///
|
|
4134
|
+
/// 0.5.30 (`.team/artifacts/add-agent-restart-saveconflict-locate.md` §5.2):
|
|
4135
|
+
/// `pub(crate)` 让 restart/rebuild.rs::rebuild_runtime_spec_from_roles 复用
|
|
4136
|
+
/// 同一去重注入逻辑,把 add-agent 记录的 dynamic_role_file 合并回 restart
|
|
4137
|
+
/// 重建 spec,防止 live helper 被 prune 后触发 SaveConflict。行为不变。
|
|
4138
|
+
pub(crate) fn inject_agent_into_spec(
|
|
4134
4139
|
spec: &mut Value,
|
|
4135
4140
|
agent: Value,
|
|
4136
4141
|
agent_id: &str,
|
|
@@ -433,7 +433,9 @@ fn restart_with_selected_team_and_transport(
|
|
|
433
433
|
&decision.agent_id,
|
|
434
434
|
&raw_agent,
|
|
435
435
|
);
|
|
436
|
-
if
|
|
436
|
+
if endpoint_convergence_fake_harness_enabled(&state)
|
|
437
|
+
&& is_fake_model_harness_agent(&agent)
|
|
438
|
+
{
|
|
437
439
|
write_fake_harness_spawn_argv_event(
|
|
438
440
|
&selected.run_workspace,
|
|
439
441
|
decision,
|
|
@@ -1242,7 +1244,7 @@ fn restart_worker_panes_addressable(
|
|
|
1242
1244
|
if decisions.is_empty() {
|
|
1243
1245
|
return true;
|
|
1244
1246
|
}
|
|
1245
|
-
if
|
|
1247
|
+
if endpoint_convergence_fake_harness_enabled(state)
|
|
1246
1248
|
&& decisions.iter().all(|decision| {
|
|
1247
1249
|
state
|
|
1248
1250
|
.get("agents")
|
|
@@ -2354,6 +2356,11 @@ fn rebuild_runtime_spec_from_roles(
|
|
|
2354
2356
|
{
|
|
2355
2357
|
crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
|
|
2356
2358
|
}
|
|
2359
|
+
// 0.5.30 (`.team/artifacts/add-agent-restart-saveconflict-locate.md` §4/§11):
|
|
2360
|
+
// 把 add-agent 记录的 dynamic_role_file 合并回 restart 重建 spec —— 单一真相 =
|
|
2361
|
+
// 静态 team_dir/agents/*.md + state 记录的 dynamic role source。缺文件 fail-closed
|
|
2362
|
+
// 三行式,不静默 prune 已 live 的 helper(persist SaveConflict 保护继续生效)。
|
|
2363
|
+
merge_state_dynamic_role_files(&mut spec, run_workspace, &team_dir, team_key, state)?;
|
|
2357
2364
|
// 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
|
|
2358
2365
|
let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
|
|
2359
2366
|
crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
|
|
@@ -2366,6 +2373,86 @@ fn rebuild_runtime_spec_from_roles(
|
|
|
2366
2373
|
Ok(spec)
|
|
2367
2374
|
}
|
|
2368
2375
|
|
|
2376
|
+
/// 0.5.30 (`add-agent-restart-saveconflict-locate.md` §4/§11): 把 add-agent
|
|
2377
|
+
/// 写入 `state.agents.<id>.dynamic_role_file` 的动态 role 文档合并回 restart
|
|
2378
|
+
/// 重建 spec。规则:
|
|
2379
|
+
/// - path 为空 / 缺失字段 → 跳过(纯静态 team_dir agent);
|
|
2380
|
+
/// - path 存在但文件 missing → fail-closed 三行式错误(不 prune live helper);
|
|
2381
|
+
/// - path 有效 → 编译成 CompiledRole,校验 compiled.id 等于 agent_id;
|
|
2382
|
+
/// - 复用 launch::inject_agent_into_spec 去重注入(名字已在 spec → 跳过)。
|
|
2383
|
+
fn merge_state_dynamic_role_files(
|
|
2384
|
+
spec: &mut YamlValue,
|
|
2385
|
+
run_workspace: &Path,
|
|
2386
|
+
team_dir: &Path,
|
|
2387
|
+
team_key: &str,
|
|
2388
|
+
state: &serde_json::Value,
|
|
2389
|
+
) -> Result<(), LifecycleError> {
|
|
2390
|
+
let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
|
|
2391
|
+
return Ok(());
|
|
2392
|
+
};
|
|
2393
|
+
if agents.is_empty() {
|
|
2394
|
+
return Ok(());
|
|
2395
|
+
}
|
|
2396
|
+
let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
|
|
2397
|
+
.map(|(meta, _)| meta)
|
|
2398
|
+
.unwrap_or(YamlValue::Null);
|
|
2399
|
+
let workspace_s = spec
|
|
2400
|
+
.get("team")
|
|
2401
|
+
.and_then(|team| team.get("workspace"))
|
|
2402
|
+
.and_then(YamlValue::as_str)
|
|
2403
|
+
.unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
|
|
2404
|
+
.to_string();
|
|
2405
|
+
let mut dynamic_ids: Vec<(String, String)> = agents
|
|
2406
|
+
.iter()
|
|
2407
|
+
.filter_map(|(agent_id, agent)| {
|
|
2408
|
+
agent
|
|
2409
|
+
.get("dynamic_role_file")
|
|
2410
|
+
.and_then(serde_json::Value::as_str)
|
|
2411
|
+
.filter(|s| !s.is_empty())
|
|
2412
|
+
.map(|raw| (agent_id.clone(), raw.to_string()))
|
|
2413
|
+
})
|
|
2414
|
+
.collect();
|
|
2415
|
+
dynamic_ids.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
2416
|
+
for (agent_id, raw_path) in dynamic_ids {
|
|
2417
|
+
let role_path = {
|
|
2418
|
+
let candidate = std::path::PathBuf::from(&raw_path);
|
|
2419
|
+
if candidate.is_absolute() {
|
|
2420
|
+
candidate
|
|
2421
|
+
} else {
|
|
2422
|
+
run_workspace.join(&candidate)
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
if !role_path.exists() {
|
|
2426
|
+
// N38 三行式:error / action / log。不 prune live helper。
|
|
2427
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
2428
|
+
"cannot restart: dynamic role file missing for agent '{agent_id}' in team \
|
|
2429
|
+
'{team_key}': {}. \
|
|
2430
|
+
action: restore the dynamic role file at that path, or run team-agent \
|
|
2431
|
+
remove-agent {agent_id} --force to drop the dynamic worker before restart. \
|
|
2432
|
+
log: workspace={}",
|
|
2433
|
+
role_path.display(),
|
|
2434
|
+
run_workspace.display(),
|
|
2435
|
+
)));
|
|
2436
|
+
}
|
|
2437
|
+
let compiled = crate::compiler::compile_role_agent(&role_path, &team_meta, &workspace_s)
|
|
2438
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2439
|
+
if compiled.id != agent_id {
|
|
2440
|
+
return Err(LifecycleError::Compile(format!(
|
|
2441
|
+
"dynamic role file for agent '{agent_id}' declares name '{}' at {}; \
|
|
2442
|
+
restart cannot rename a live worker. \
|
|
2443
|
+
action: fix the role file's front-matter name to match agent_id, or run \
|
|
2444
|
+
team-agent remove-agent {agent_id} --force before restart. \
|
|
2445
|
+
log: workspace={}",
|
|
2446
|
+
compiled.id,
|
|
2447
|
+
role_path.display(),
|
|
2448
|
+
run_workspace.display(),
|
|
2449
|
+
)));
|
|
2450
|
+
}
|
|
2451
|
+
crate::lifecycle::launch::inject_agent_into_spec(spec, compiled.agent, &compiled.id)?;
|
|
2452
|
+
}
|
|
2453
|
+
Ok(())
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2369
2456
|
fn load_endpoint_convergence_runtime_spec(
|
|
2370
2457
|
run_workspace: &Path,
|
|
2371
2458
|
team_key: &str,
|
|
@@ -2404,6 +2491,11 @@ fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
|
|
|
2404
2491
|
== Some("converged")
|
|
2405
2492
|
}
|
|
2406
2493
|
|
|
2494
|
+
fn endpoint_convergence_fake_harness_enabled(state: &serde_json::Value) -> bool {
|
|
2495
|
+
has_endpoint_convergence_marker(state)
|
|
2496
|
+
&& std::env::var_os("TEAM_AGENT_TEST_ENDPOINT_CONVERGENCE_HARNESS_SPEC_FALLBACK").is_some()
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2407
2499
|
fn is_fake_model_harness_agent(agent: &serde_json::Value) -> bool {
|
|
2408
2500
|
agent_provider(agent) == crate::model::enums::Provider::Fake
|
|
2409
2501
|
&& agent.get("model").and_then(serde_json::Value::as_str) == Some("fake")
|