@team-agent/installer 0.3.6 → 0.3.8
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 +52 -7
- package/crates/team-agent/src/cli/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +175 -0
- package/crates/team-agent/src/cli/mod.rs +455 -63
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
- package/crates/team-agent/src/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/lifecycle/launch.rs +378 -56
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +381 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +104 -9
- package/crates/team-agent/src/transport/test_support.rs +57 -4
- package/crates/team-agent/src/transport.rs +13 -0
- package/npm/install.mjs +31 -35
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +82 -5
|
@@ -139,7 +139,20 @@ fn spawn_agents(
|
|
|
139
139
|
safety: &DangerousApproval,
|
|
140
140
|
transport: &dyn Transport,
|
|
141
141
|
) -> Result<Vec<StartedAgent>, LifecycleError> {
|
|
142
|
-
|
|
142
|
+
// E5 解耦:team_dir(角色定义 + profiles 所在)≠ spec_path.parent()(spec 已迁出到 .team/runtime)。
|
|
143
|
+
// 优先取 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
|
|
144
|
+
let team_dir_buf = crate::state::persist::load_runtime_state(workspace)
|
|
145
|
+
.ok()
|
|
146
|
+
.and_then(|state| {
|
|
147
|
+
state
|
|
148
|
+
.get("team_dir")
|
|
149
|
+
.and_then(serde_json::Value::as_str)
|
|
150
|
+
.filter(|s| !s.is_empty())
|
|
151
|
+
.map(PathBuf::from)
|
|
152
|
+
});
|
|
153
|
+
let team_dir = team_dir_buf
|
|
154
|
+
.as_deref()
|
|
155
|
+
.unwrap_or_else(|| spec_path.parent().unwrap_or_else(|| Path::new(".")));
|
|
143
156
|
let runtime_fast = matches!(
|
|
144
157
|
spec.get("runtime").and_then(|v| v.get("fast")),
|
|
145
158
|
Some(Value::Bool(true))
|
|
@@ -313,6 +326,28 @@ fn spawn_agents(
|
|
|
313
326
|
);
|
|
314
327
|
}
|
|
315
328
|
}
|
|
329
|
+
// E6 层1 实证3 + 诊断留痕:落最终 worker argv(spawn 前的真实形态)。
|
|
330
|
+
// 任何"--session-id 预定 UUID 没生效"必须能从 events.jsonl 回答:argv 里到底有没有它。
|
|
331
|
+
// 抽出 --session-id 值单列,方便和盘上 ~/.claude/projects/<cwd> 实际落的 UUID 对账。
|
|
332
|
+
{
|
|
333
|
+
let session_id_in_argv = plan
|
|
334
|
+
.argv
|
|
335
|
+
.iter()
|
|
336
|
+
.position(|a| a == "--session-id")
|
|
337
|
+
.and_then(|i| plan.argv.get(i + 1))
|
|
338
|
+
.cloned();
|
|
339
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
340
|
+
let _ = event_log.write(
|
|
341
|
+
"provider.worker.spawn_argv",
|
|
342
|
+
serde_json::json!({
|
|
343
|
+
"agent_id": agent_id_raw,
|
|
344
|
+
"provider": provider,
|
|
345
|
+
"argv": plan.argv,
|
|
346
|
+
"session_id_in_argv": session_id_in_argv,
|
|
347
|
+
"expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
316
351
|
let spawn = if started.is_empty() {
|
|
317
352
|
transport.spawn_first_with_env_unset(
|
|
318
353
|
session_name,
|
|
@@ -402,7 +437,14 @@ fn persist_spawn_agent_state(
|
|
|
402
437
|
.map(|agent| agent.agent_id.as_str().to_string())
|
|
403
438
|
.collect();
|
|
404
439
|
let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
|
|
405
|
-
|
|
440
|
+
// E5 解耦:profiles 随**角色定义**(team_dir),不随 spec(已迁出到 .team/runtime)。
|
|
441
|
+
// 优先 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
|
|
442
|
+
let profile_dir = state
|
|
443
|
+
.get("team_dir")
|
|
444
|
+
.and_then(serde_json::Value::as_str)
|
|
445
|
+
.filter(|s| !s.is_empty())
|
|
446
|
+
.map(|dir| Path::new(dir).join("profiles"))
|
|
447
|
+
.unwrap_or_else(|| spec_path.parent().unwrap_or(workspace).join("profiles"));
|
|
406
448
|
let mut agents = serde_json::Map::new();
|
|
407
449
|
let mut spawn_index = 0_u32;
|
|
408
450
|
for agent in spec_agent_values(spec) {
|
|
@@ -720,6 +762,171 @@ fn env_nonempty(key: &str) -> bool {
|
|
|
720
762
|
.is_some_and(|value| !value.is_empty())
|
|
721
763
|
}
|
|
722
764
|
|
|
765
|
+
/// B-7 / 036b — TEAM_AGENT_LEADER_PANE_ID 主动路径 fail-fast helper。
|
|
766
|
+
/// 入口形态(N38 三行式):
|
|
767
|
+
/// error : `TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: %<id>`
|
|
768
|
+
/// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
|
|
769
|
+
/// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
|
|
770
|
+
/// env 未设(或空)→ Ok(())。
|
|
771
|
+
/// env 设了但 pane 可判定为 Dead/Absent → Err(RequirementUnmet)。
|
|
772
|
+
/// 真实 tmux 后端跨所有现存 tmux socket server 探测:TEAM_AGENT_LEADER_PANE_ID 是用户
|
|
773
|
+
/// override 指针,不归属当前 team socket。
|
|
774
|
+
/// probe 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead/Absent】fail-fast,
|
|
775
|
+
/// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
|
|
776
|
+
pub(crate) fn validate_active_leader_pane_env(
|
|
777
|
+
transport: &dyn Transport,
|
|
778
|
+
) -> Result<(), LifecycleError> {
|
|
779
|
+
validate_active_leader_pane_env_with_workspaces(transport, &[])
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
pub(crate) fn validate_active_leader_pane_env_with_workspace(
|
|
783
|
+
transport: &dyn Transport,
|
|
784
|
+
workspace: Option<&Path>,
|
|
785
|
+
) -> Result<(), LifecycleError> {
|
|
786
|
+
let workspaces = workspace.into_iter().collect::<Vec<_>>();
|
|
787
|
+
validate_active_leader_pane_env_with_workspaces(transport, &workspaces)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
pub(crate) fn validate_active_leader_pane_env_with_workspaces(
|
|
791
|
+
transport: &dyn Transport,
|
|
792
|
+
workspaces: &[&Path],
|
|
793
|
+
) -> Result<(), LifecycleError> {
|
|
794
|
+
let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
|
|
795
|
+
Ok(v) if !v.is_empty() => v,
|
|
796
|
+
_ => return Ok(()),
|
|
797
|
+
};
|
|
798
|
+
let pane = crate::transport::PaneId::new(&pane_id_raw);
|
|
799
|
+
if !is_tmux_pane_id_format(&pane) {
|
|
800
|
+
write_invalid_leader_pane_env_warning(workspaces, &pane_id_raw);
|
|
801
|
+
return Ok(());
|
|
802
|
+
}
|
|
803
|
+
let failure = match leader_pane_env_state_for_validation(transport, &pane) {
|
|
804
|
+
LeaderPaneEnvState::Dead => Some("dead"),
|
|
805
|
+
LeaderPaneEnvState::Absent => Some("absent"),
|
|
806
|
+
LeaderPaneEnvState::Live | LeaderPaneEnvState::Unknown => None,
|
|
807
|
+
};
|
|
808
|
+
let Some(reason) = failure else {
|
|
809
|
+
return Ok(());
|
|
810
|
+
};
|
|
811
|
+
Err(LifecycleError::RequirementUnmet(format!(
|
|
812
|
+
"TEAM_AGENT_LEADER_PANE_ID points at a {reason} pane: {pane_id_raw}\n\
|
|
813
|
+
action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
|
|
814
|
+
log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
|
|
815
|
+
)))
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str) {
|
|
819
|
+
let message = "invalid pane id format, skipping validation";
|
|
820
|
+
let mut wrote = false;
|
|
821
|
+
let mut errors = Vec::new();
|
|
822
|
+
let mut seen = BTreeSet::new();
|
|
823
|
+
for workspace in workspaces {
|
|
824
|
+
let key = workspace.to_string_lossy().to_string();
|
|
825
|
+
if !seen.insert(key.clone()) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
match crate::event_log::EventLog::new(workspace).write(
|
|
829
|
+
"leader_pane_env.validation_warning",
|
|
830
|
+
serde_json::json!({
|
|
831
|
+
"env": "TEAM_AGENT_LEADER_PANE_ID",
|
|
832
|
+
"value": pane_id_raw,
|
|
833
|
+
"warning": message,
|
|
834
|
+
}),
|
|
835
|
+
) {
|
|
836
|
+
Ok(_) => wrote = true,
|
|
837
|
+
Err(err) => errors.push(format!("{key}: {err}")),
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if !wrote {
|
|
841
|
+
eprintln!("TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}: {message}");
|
|
842
|
+
if !errors.is_empty() {
|
|
843
|
+
eprintln!(
|
|
844
|
+
"TEAM_AGENT_LEADER_PANE_ID warning event write failed: {}",
|
|
845
|
+
errors.join("; ")
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
852
|
+
pub(crate) enum LeaderPaneEnvState {
|
|
853
|
+
Live,
|
|
854
|
+
Dead,
|
|
855
|
+
Absent,
|
|
856
|
+
Unknown,
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
fn leader_pane_env_state_for_validation(
|
|
860
|
+
transport: &dyn Transport,
|
|
861
|
+
pane: &crate::transport::PaneId,
|
|
862
|
+
) -> LeaderPaneEnvState {
|
|
863
|
+
if !is_tmux_pane_id_format(pane) {
|
|
864
|
+
return LeaderPaneEnvState::Unknown;
|
|
865
|
+
}
|
|
866
|
+
if transport.probes_real_tmux_socket_roots() {
|
|
867
|
+
return active_leader_pane_state_across_tmux_sockets(pane);
|
|
868
|
+
}
|
|
869
|
+
active_leader_pane_state(transport, pane)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
fn is_tmux_pane_id_format(pane: &crate::transport::PaneId) -> bool {
|
|
873
|
+
let pane = pane.as_str();
|
|
874
|
+
pane.len() > 1 && pane.starts_with('%') && pane[1..].chars().all(|ch| ch.is_ascii_digit())
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
fn active_leader_pane_state_across_tmux_sockets(
|
|
878
|
+
pane: &crate::transport::PaneId,
|
|
879
|
+
) -> LeaderPaneEnvState {
|
|
880
|
+
let endpoints = crate::tmux_backend::tmux_socket_endpoints();
|
|
881
|
+
let transports = endpoints
|
|
882
|
+
.iter()
|
|
883
|
+
.map(|endpoint| crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint))
|
|
884
|
+
.collect::<Vec<_>>();
|
|
885
|
+
active_leader_pane_state_across_transports(
|
|
886
|
+
transports.iter().map(|transport| transport as &dyn Transport),
|
|
887
|
+
pane,
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
pub(crate) fn active_leader_pane_state_across_transports<'a>(
|
|
892
|
+
transports: impl IntoIterator<Item = &'a dyn Transport>,
|
|
893
|
+
pane: &crate::transport::PaneId,
|
|
894
|
+
) -> LeaderPaneEnvState {
|
|
895
|
+
let mut found_absent = false;
|
|
896
|
+
let mut found_dead = false;
|
|
897
|
+
for transport in transports {
|
|
898
|
+
match active_leader_pane_state(transport, pane) {
|
|
899
|
+
LeaderPaneEnvState::Live => return LeaderPaneEnvState::Live,
|
|
900
|
+
LeaderPaneEnvState::Dead => found_dead = true,
|
|
901
|
+
LeaderPaneEnvState::Absent => found_absent = true,
|
|
902
|
+
LeaderPaneEnvState::Unknown => {}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if found_dead {
|
|
906
|
+
LeaderPaneEnvState::Dead
|
|
907
|
+
} else if found_absent {
|
|
908
|
+
LeaderPaneEnvState::Absent
|
|
909
|
+
} else {
|
|
910
|
+
LeaderPaneEnvState::Unknown
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
fn active_leader_pane_state(
|
|
915
|
+
transport: &dyn Transport,
|
|
916
|
+
pane: &crate::transport::PaneId,
|
|
917
|
+
) -> LeaderPaneEnvState {
|
|
918
|
+
match transport.has_pane(pane) {
|
|
919
|
+
Ok(Some(true)) => return LeaderPaneEnvState::Live,
|
|
920
|
+
Ok(Some(false)) => return LeaderPaneEnvState::Absent,
|
|
921
|
+
Ok(None) | Err(_) => {}
|
|
922
|
+
}
|
|
923
|
+
match transport.liveness(pane) {
|
|
924
|
+
Ok(crate::transport::PaneLiveness::Live) => LeaderPaneEnvState::Live,
|
|
925
|
+
Ok(crate::transport::PaneLiveness::Dead) => LeaderPaneEnvState::Dead,
|
|
926
|
+
Ok(crate::transport::PaneLiveness::Unknown) | Err(_) => LeaderPaneEnvState::Unknown,
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
723
930
|
fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
724
931
|
let Some(owner) = unbound_launched_owner(launched, launched_key) else {
|
|
725
932
|
return;
|
|
@@ -1809,6 +2016,14 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1809
2016
|
team_id: Option<&str>,
|
|
1810
2017
|
transport: &dyn Transport,
|
|
1811
2018
|
) -> Result<QuickStartReport, LifecycleError> {
|
|
2019
|
+
// B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
|
|
2020
|
+
// 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
|
|
2021
|
+
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
2022
|
+
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
2023
|
+
// 或修 env)/log(env var 名)。
|
|
2024
|
+
let team_workspace = team_workspace(agents_dir);
|
|
2025
|
+
let warning_workspaces = [workspace, team_workspace.as_path()];
|
|
2026
|
+
validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
|
|
1812
2027
|
if !agents_dir.exists() {
|
|
1813
2028
|
return Err(LifecycleError::Compile(format!(
|
|
1814
2029
|
"agents dir not found: {}",
|
|
@@ -1844,18 +2059,40 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1844
2059
|
.as_deref()
|
|
1845
2060
|
.is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
|
|
1846
2061
|
{
|
|
2062
|
+
let session_name = state
|
|
2063
|
+
.get("session_name")
|
|
2064
|
+
.and_then(serde_json::Value::as_str)
|
|
2065
|
+
.filter(|s| !s.is_empty())
|
|
2066
|
+
.map(SessionName::new);
|
|
2067
|
+
let attach_commands = session_name
|
|
2068
|
+
.as_ref()
|
|
2069
|
+
.map(|session| {
|
|
2070
|
+
let windows = quick_start_attach_window_names(&state);
|
|
2071
|
+
crate::tmux_backend::attach_commands_for_windows(
|
|
2072
|
+
&workspace,
|
|
2073
|
+
session,
|
|
2074
|
+
windows.iter().map(String::as_str),
|
|
2075
|
+
)
|
|
2076
|
+
})
|
|
2077
|
+
.unwrap_or_default();
|
|
2078
|
+
let mut next_actions = vec![
|
|
2079
|
+
"run restart to resume the existing team or pass --fresh to replace it"
|
|
2080
|
+
.to_string(),
|
|
2081
|
+
];
|
|
2082
|
+
if session_name.is_some() {
|
|
2083
|
+
if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
|
|
2084
|
+
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
|
|
2085
|
+
&workspace,
|
|
2086
|
+
));
|
|
2087
|
+
}
|
|
2088
|
+
next_actions.extend(attach_commands.iter().cloned());
|
|
2089
|
+
}
|
|
1847
2090
|
return Ok(QuickStartReport::ExistingRuntime {
|
|
1848
2091
|
team: requested_team.clone(),
|
|
1849
|
-
session_name
|
|
1850
|
-
.get("session_name")
|
|
1851
|
-
.and_then(serde_json::Value::as_str)
|
|
1852
|
-
.filter(|s| !s.is_empty())
|
|
1853
|
-
.map(SessionName::new),
|
|
2092
|
+
session_name,
|
|
1854
2093
|
state_path: Some(state_path),
|
|
1855
|
-
next_actions
|
|
1856
|
-
|
|
1857
|
-
.to_string(),
|
|
1858
|
-
],
|
|
2094
|
+
next_actions,
|
|
2095
|
+
attach_commands,
|
|
1859
2096
|
});
|
|
1860
2097
|
}
|
|
1861
2098
|
}
|
|
@@ -1869,13 +2106,14 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1869
2106
|
override_spec_session_name(&mut spec, &format!("team-{requested}"));
|
|
1870
2107
|
}
|
|
1871
2108
|
let session_name = spec_session_name(&spec);
|
|
2109
|
+
// team_key 身份源 = team_dir(agents_dir).name(角色定义目录),不依赖 spec 落点。
|
|
1872
2110
|
let state_team_key = explicit_team_key.clone().unwrap_or_else(|| {
|
|
1873
|
-
|
|
1874
|
-
runtime_team_key_for_spec(&spec_path, &spec, &session_name)
|
|
2111
|
+
runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
|
|
1875
2112
|
});
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
2113
|
+
// E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
|
|
2114
|
+
// Bug2:原子写(tmp+rename),避免半截 spec。
|
|
2115
|
+
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
|
|
2116
|
+
write_spec_atomic(&spec_path, &spec)?;
|
|
1879
2117
|
let _store = crate::message_store::MessageStore::open(&workspace)
|
|
1880
2118
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
1881
2119
|
let resolved_spec_path =
|
|
@@ -1931,6 +2169,9 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1931
2169
|
let mut next_actions = vec![format!(
|
|
1932
2170
|
"team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
|
|
1933
2171
|
)];
|
|
2172
|
+
if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
|
|
2173
|
+
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(&workspace));
|
|
2174
|
+
}
|
|
1934
2175
|
next_actions.extend(attach_commands.iter().cloned());
|
|
1935
2176
|
let display_backend = state
|
|
1936
2177
|
.get("display_backend")
|
|
@@ -1947,6 +2188,29 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1947
2188
|
})
|
|
1948
2189
|
}
|
|
1949
2190
|
|
|
2191
|
+
fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
|
|
2192
|
+
let mut windows = state
|
|
2193
|
+
.get("agents")
|
|
2194
|
+
.and_then(serde_json::Value::as_object)
|
|
2195
|
+
.map(|agents| {
|
|
2196
|
+
agents
|
|
2197
|
+
.iter()
|
|
2198
|
+
.filter_map(|(agent_id, agent)| {
|
|
2199
|
+
agent
|
|
2200
|
+
.get("window")
|
|
2201
|
+
.and_then(serde_json::Value::as_str)
|
|
2202
|
+
.filter(|window| !window.is_empty())
|
|
2203
|
+
.map(str::to_string)
|
|
2204
|
+
.or_else(|| Some(agent_id.clone()))
|
|
2205
|
+
})
|
|
2206
|
+
.collect::<Vec<_>>()
|
|
2207
|
+
})
|
|
2208
|
+
.unwrap_or_default();
|
|
2209
|
+
windows.sort();
|
|
2210
|
+
windows.dedup();
|
|
2211
|
+
windows
|
|
2212
|
+
}
|
|
2213
|
+
|
|
1950
2214
|
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
1951
2215
|
/// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
|
|
1952
2216
|
/// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
|
|
@@ -2268,9 +2532,8 @@ pub fn add_agent(
|
|
|
2268
2532
|
}
|
|
2269
2533
|
Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
|
|
2270
2534
|
};
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
})?;
|
|
2535
|
+
// E5 §3:compile_team 要角色定义目录(team_dir),不是 spec 落点(spec_workspace=runtime)。
|
|
2536
|
+
let team_dir = selected.team_dir;
|
|
2274
2537
|
add_agent_with_transport_at_paths(
|
|
2275
2538
|
&selected.run_workspace,
|
|
2276
2539
|
&team_dir,
|
|
@@ -2336,21 +2599,40 @@ fn add_agent_with_transport_at_paths(
|
|
|
2336
2599
|
"agent id already exists: {agent_id}"
|
|
2337
2600
|
)));
|
|
2338
2601
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2602
|
+
// E5 Bug1:不再 copy role 文件进 <team_dir>/agents(自拷贝 O_TRUNC 截断反模式)。
|
|
2603
|
+
// 就地读外部 role 文档编译,注入 base team spec 的 agents/routing。role 文件留在原处。
|
|
2604
|
+
let mut spec = crate::compiler::compile_team(team_dir)
|
|
2341
2605
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2606
|
+
let workspace_s = spec
|
|
2607
|
+
.get("team")
|
|
2608
|
+
.and_then(|team| team.get("workspace"))
|
|
2609
|
+
.and_then(Value::as_str)
|
|
2610
|
+
.unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
|
|
2611
|
+
.to_string();
|
|
2612
|
+
let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
|
|
2613
|
+
.map(|(meta, _)| meta)
|
|
2614
|
+
.unwrap_or(Value::Null);
|
|
2615
|
+
let compiled = crate::compiler::compile_role_agent(role_file_path, &team_meta, &workspace_s)
|
|
2616
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2617
|
+
if compiled.id != agent_id.as_str() {
|
|
2618
|
+
return Err(LifecycleError::Compile(format!(
|
|
2619
|
+
"role file declares name '{}' but add-agent id is '{}'",
|
|
2620
|
+
compiled.id, agent_id
|
|
2621
|
+
)));
|
|
2622
|
+
}
|
|
2623
|
+
inject_agent_into_spec(&mut spec, compiled.agent, &compiled.id)?;
|
|
2342
2624
|
let safety = effective_runtime_config(&spec)?;
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
let (meta, _) = crate::compiler::read_front_matter(
|
|
2625
|
+
// E5 spec 迁移:重编译的 spec 原子写到 .team/runtime/<team_key>/(不落用户目录 team_dir)。
|
|
2626
|
+
let spec_path = crate::model::paths::runtime_spec_path(run_workspace, &canonical_team_key);
|
|
2627
|
+
write_spec_atomic(&spec_path, &spec)?;
|
|
2628
|
+
let (meta, _) = crate::compiler::read_front_matter(role_file_path)
|
|
2347
2629
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2348
2630
|
upsert_agent_state_from_role(
|
|
2349
2631
|
run_workspace,
|
|
2350
2632
|
&canonical_team_key,
|
|
2351
2633
|
agent_id,
|
|
2352
2634
|
&meta,
|
|
2353
|
-
|
|
2635
|
+
role_file_path,
|
|
2354
2636
|
&safety,
|
|
2355
2637
|
)?;
|
|
2356
2638
|
let started = crate::lifecycle::restart::start_agent_at_paths(
|
|
@@ -2457,26 +2739,39 @@ fn upsert_agent_state_from_role(
|
|
|
2457
2739
|
save_launched_team_state_for_key(workspace, &state, Some(canonical_team_key))
|
|
2458
2740
|
}
|
|
2459
2741
|
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
LifecycleError::
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
))
|
|
2478
|
-
|
|
2479
|
-
|
|
2742
|
+
/// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
|
|
2743
|
+
/// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
|
|
2744
|
+
fn inject_agent_into_spec(
|
|
2745
|
+
spec: &mut Value,
|
|
2746
|
+
agent: Value,
|
|
2747
|
+
agent_id: &str,
|
|
2748
|
+
) -> Result<(), LifecycleError> {
|
|
2749
|
+
let Value::Map(pairs) = spec else {
|
|
2750
|
+
return Err(LifecycleError::Compile("spec is not a map".to_string()));
|
|
2751
|
+
};
|
|
2752
|
+
// agents 列表追加。
|
|
2753
|
+
match pairs.iter_mut().find(|(k, _)| k == "agents") {
|
|
2754
|
+
Some((_, Value::List(agents))) => agents.push(agent),
|
|
2755
|
+
_ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
|
|
2756
|
+
}
|
|
2757
|
+
// routing.rules 追加 route-<id>(与 compile_team 同形)。
|
|
2758
|
+
if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
|
|
2759
|
+
if let Some((_, Value::List(rules))) = routing.iter_mut().find(|(k, _)| k == "rules") {
|
|
2760
|
+
rules.push(Value::Map(vec![
|
|
2761
|
+
("id".to_string(), Value::Str(format!("route-{agent_id}"))),
|
|
2762
|
+
(
|
|
2763
|
+
"match".to_string(),
|
|
2764
|
+
Value::Map(vec![(
|
|
2765
|
+
"assignee".to_string(),
|
|
2766
|
+
Value::List(vec![Value::Str(agent_id.to_string())]),
|
|
2767
|
+
)]),
|
|
2768
|
+
),
|
|
2769
|
+
("assign_to".to_string(), Value::Str(agent_id.to_string())),
|
|
2770
|
+
("priority".to_string(), Value::Int(10)),
|
|
2771
|
+
]));
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
Ok(())
|
|
2480
2775
|
}
|
|
2481
2776
|
|
|
2482
2777
|
/// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
|
|
@@ -2523,15 +2818,18 @@ pub fn fork_agent_with_transport(
|
|
|
2523
2818
|
crate::state::selector::SelectorMode::RequireSpec,
|
|
2524
2819
|
)
|
|
2525
2820
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
2526
|
-
|
|
2527
|
-
|
|
2821
|
+
// E5 §3:team_dir(角色定义+profiles)恒用户目录。spec 读用 selector 解析的 spec_path
|
|
2822
|
+
// (读序 B:runtime 优先、legacy 回落),写恒走 runtime_spec_path(canonical 落点)。
|
|
2823
|
+
let fork_team_dir = selected.team_dir.clone();
|
|
2824
|
+
let read_spec_path = selected.spec_path.clone().ok_or_else(|| {
|
|
2825
|
+
LifecycleError::TeamSelect("active team spec not found".to_string())
|
|
2528
2826
|
})?;
|
|
2529
2827
|
let workspace = selected.run_workspace;
|
|
2530
2828
|
let state = selected.state;
|
|
2531
2829
|
ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
|
|
2532
|
-
let spec_path =
|
|
2533
|
-
let text = std::fs::read_to_string(&
|
|
2534
|
-
.map_err(|e| LifecycleError::Compile(format!("{}: {e}",
|
|
2830
|
+
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &selected.team_key);
|
|
2831
|
+
let text = std::fs::read_to_string(&read_spec_path)
|
|
2832
|
+
.map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
|
|
2535
2833
|
let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2536
2834
|
if find_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
|
|
2537
2835
|
return Err(LifecycleError::RequirementUnmet(format!(
|
|
@@ -2571,10 +2869,12 @@ pub fn fork_agent_with_transport(
|
|
|
2571
2869
|
)));
|
|
2572
2870
|
}
|
|
2573
2871
|
let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id, label)?;
|
|
2574
|
-
|
|
2872
|
+
// validate 用角色定义目录的 team_workspace(校验 working_directory),非 spec 落点。
|
|
2873
|
+
let validate_ws = crate::model::paths::team_workspace(&fork_team_dir)
|
|
2874
|
+
.unwrap_or_else(|_| workspace.clone());
|
|
2875
|
+
crate::model::spec::validate_spec(&new_spec, &validate_ws)
|
|
2575
2876
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2576
|
-
|
|
2577
|
-
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
|
|
2877
|
+
write_spec_atomic(&spec_path, &new_spec)?;
|
|
2578
2878
|
let new_agent = find_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
|
|
2579
2879
|
LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
|
|
2580
2880
|
})?;
|
|
@@ -2630,7 +2930,8 @@ pub fn fork_agent_with_transport(
|
|
|
2630
2930
|
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
2631
2931
|
e
|
|
2632
2932
|
})?;
|
|
2633
|
-
|
|
2933
|
+
// E5 §3:profiles 随角色定义目录(team_dir),不随已迁出的 spec。
|
|
2934
|
+
let profile_dir = fork_team_dir.join("profiles");
|
|
2634
2935
|
let profile_launch =
|
|
2635
2936
|
crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
|
|
2636
2937
|
&workspace,
|
|
@@ -3314,7 +3615,28 @@ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
|
|
|
3314
3615
|
/// `runtime` map and/or the `session_name` entry if absent. Used by quick-start to
|
|
3315
3616
|
/// derive the tmux session from the REQUESTED team identity (CR-040/042) rather
|
|
3316
3617
|
/// than the template's compiled-in name.
|
|
3317
|
-
|
|
3618
|
+
/// E5 Bug2(atomic 真修):原子写 runtime spec —— 写 `<spec>.tmp-<pid>` 再 rename 覆盖,
|
|
3619
|
+
/// 避免崩溃/并发留下半截 spec(plain fs::write 会 in-place truncate 后逐字节写)。
|
|
3620
|
+
/// rename 失败时清理 tmp,原 spec(若有)不动。
|
|
3621
|
+
pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), LifecycleError> {
|
|
3622
|
+
if let Some(parent) = spec_path.parent() {
|
|
3623
|
+
std::fs::create_dir_all(parent)
|
|
3624
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
|
|
3625
|
+
}
|
|
3626
|
+
let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
|
|
3627
|
+
std::fs::write(&tmp, yaml::dumps(spec))
|
|
3628
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
|
|
3629
|
+
if let Err(e) = std::fs::rename(&tmp, spec_path) {
|
|
3630
|
+
let _ = std::fs::remove_file(&tmp);
|
|
3631
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
3632
|
+
"{}: {e}",
|
|
3633
|
+
spec_path.display()
|
|
3634
|
+
)));
|
|
3635
|
+
}
|
|
3636
|
+
Ok(())
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
|
|
3318
3640
|
let Value::Map(root) = spec else { return };
|
|
3319
3641
|
let runtime_slot = root
|
|
3320
3642
|
.iter_mut()
|
|
@@ -331,15 +331,10 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
|
|
|
331
331
|
.get("status")
|
|
332
332
|
.and_then(|value| value.as_str())
|
|
333
333
|
.is_some_and(|status| status == "running");
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
let has_interaction_marker = agent
|
|
339
|
-
.get("first_send_at")
|
|
340
|
-
.and_then(|value| value.as_str())
|
|
341
|
-
.is_some_and(|value| !value.is_empty());
|
|
342
|
-
missing_session_id && is_running && (has_live_pane_binding || has_interaction_marker)
|
|
334
|
+
// E6 层2 (C2): required-missing 谓词只看 session_id 有无 + 是否在跑。
|
|
335
|
+
// pane 绑定 / first_send_at 在 gate 时刻天然可空(自启动 worker leader 从未发消息),
|
|
336
|
+
// 不能作判据 —— 否则真丢上下文的 null-session worker 被漏判,走静默 fresh。
|
|
337
|
+
missing_session_id && is_running
|
|
343
338
|
})
|
|
344
339
|
.collect::<Vec<_>>();
|
|
345
340
|
missing.sort();
|