@team-agent/installer 0.5.50 → 0.5.51
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 +5 -3
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +32 -14
- package/crates/team-agent/src/cli/named_address.rs +11 -48
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +44 -1169
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
- package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
- package/crates/team-agent/src/mcp_server/tools.rs +14 -5
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +165 -39
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +10 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +101 -49
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +2 -1
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +59 -16
- package/package.json +4 -4
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
use std::process::Command;
|
|
4
|
+
|
|
5
|
+
use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
|
|
6
|
+
use crate::lifecycle::profile_launch::parse_provider;
|
|
7
|
+
use crate::lifecycle::*;
|
|
8
|
+
use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
|
|
9
|
+
use crate::model::ids::AgentId;
|
|
10
|
+
use crate::model::permissions::{self, AgentPermissionInput};
|
|
11
|
+
use crate::model::yaml::{self, Value};
|
|
12
|
+
use crate::state::persist::load_runtime_state;
|
|
13
|
+
use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
|
|
14
|
+
|
|
15
|
+
use super::agent_state::running_agent_state;
|
|
16
|
+
use super::identity::{explicit_active_team_key, runtime_team_key_for_spec};
|
|
17
|
+
use super::leader_context::{
|
|
18
|
+
owner_pane_belongs_to_other_team, seed_unbound_launched_owner, unbound_launched_owner,
|
|
19
|
+
};
|
|
20
|
+
use super::spec_state::{has_positive_caller_leader_env, spec_agent_values};
|
|
21
|
+
use super::worker_env::{agent_is_paused, spawn_timestamp_for_agent};
|
|
22
|
+
|
|
23
|
+
pub(super) fn persist_spawn_agent_state(
|
|
24
|
+
workspace: &Path,
|
|
25
|
+
spec_path: &Path,
|
|
26
|
+
spec: &Value,
|
|
27
|
+
session_name: &SessionName,
|
|
28
|
+
transport: &dyn Transport,
|
|
29
|
+
started: &[StartedAgent],
|
|
30
|
+
safety: &DangerousApproval,
|
|
31
|
+
) -> Result<(), LifecycleError> {
|
|
32
|
+
let state_path = crate::state::persist::runtime_state_path(workspace);
|
|
33
|
+
let mut state = match crate::state::repository::StateRepository::new(workspace)
|
|
34
|
+
.load_workspace_if_exists_without_migrations()
|
|
35
|
+
{
|
|
36
|
+
Ok(Some(state)) => state,
|
|
37
|
+
Ok(None) => serde_json::json!({"agents": {}}),
|
|
38
|
+
Err(crate::state::StateError::Io(error)) => {
|
|
39
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
40
|
+
"{}: {error}",
|
|
41
|
+
state_path.display()
|
|
42
|
+
)))
|
|
43
|
+
}
|
|
44
|
+
Err(crate::state::StateError::Json(error)) => {
|
|
45
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
46
|
+
"{}: {error}",
|
|
47
|
+
state_path.display()
|
|
48
|
+
)))
|
|
49
|
+
}
|
|
50
|
+
Err(error) => {
|
|
51
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
52
|
+
"{}: {error}",
|
|
53
|
+
state_path.display()
|
|
54
|
+
)))
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
let team_id = runtime_team_key_for_spec(spec_path, spec, session_name);
|
|
58
|
+
let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
|
|
59
|
+
drop_worker_pane_seeded_owner(&mut state, &team_id, started, worker_tmux_socket.as_deref());
|
|
60
|
+
// Only persist running state for agents whose spawn still has a live target.
|
|
61
|
+
let live_windows: BTreeSet<String> = transport
|
|
62
|
+
.list_windows(session_name)
|
|
63
|
+
.unwrap_or_default()
|
|
64
|
+
.into_iter()
|
|
65
|
+
.map(|w| w.as_str().to_string())
|
|
66
|
+
.collect();
|
|
67
|
+
let live_started_agents: BTreeSet<String> = started
|
|
68
|
+
.iter()
|
|
69
|
+
.map(|agent| agent.agent_id.as_str().to_string())
|
|
70
|
+
.collect();
|
|
71
|
+
let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
|
|
72
|
+
// E5 解耦:profiles 随**角色定义**(team_dir),不随 spec(已迁出到 .team/runtime)。
|
|
73
|
+
// 优先 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
|
|
74
|
+
let profile_dir = state
|
|
75
|
+
.get("team_dir")
|
|
76
|
+
.and_then(serde_json::Value::as_str)
|
|
77
|
+
.filter(|s| !s.is_empty())
|
|
78
|
+
.map(|dir| Path::new(dir).join("profiles"))
|
|
79
|
+
.unwrap_or_else(|| spec_path.parent().unwrap_or(workspace).join("profiles"));
|
|
80
|
+
let mut agents = serde_json::Map::new();
|
|
81
|
+
let mut spawn_index = 0_u32;
|
|
82
|
+
for agent in spec_agent_values(spec) {
|
|
83
|
+
let Some(id) = agent.get("id").and_then(Value::as_str) else {
|
|
84
|
+
continue;
|
|
85
|
+
};
|
|
86
|
+
let provider = agent
|
|
87
|
+
.get("provider")
|
|
88
|
+
.and_then(Value::as_str)
|
|
89
|
+
.and_then(parse_provider)
|
|
90
|
+
.unwrap_or(Provider::Codex);
|
|
91
|
+
if agent_is_paused(agent) {
|
|
92
|
+
let mut paused = serde_json::Map::new();
|
|
93
|
+
paused.insert("status".to_string(), serde_json::json!("paused"));
|
|
94
|
+
paused.insert("provider".to_string(), serde_json::json!(provider));
|
|
95
|
+
agents.insert(id.to_string(), serde_json::Value::Object(paused));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
let started_agent = started.iter().find(|agent| agent.agent_id.as_str() == id);
|
|
99
|
+
let window = started_agent
|
|
100
|
+
.and_then(|started| started.layout_window.as_ref())
|
|
101
|
+
.map(WindowName::as_str)
|
|
102
|
+
.or_else(|| agent.get("window").and_then(Value::as_str))
|
|
103
|
+
.unwrap_or(id);
|
|
104
|
+
if !live_started_agents.contains(id)
|
|
105
|
+
|| (!live_windows.is_empty() && !live_windows.contains(window))
|
|
106
|
+
{
|
|
107
|
+
let mut failed = serde_json::Map::new();
|
|
108
|
+
failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
|
|
109
|
+
failed.insert("provider".to_string(), serde_json::json!(provider));
|
|
110
|
+
failed.insert("agent_id".to_string(), serde_json::json!(id));
|
|
111
|
+
failed.insert("window".to_string(), serde_json::json!(window));
|
|
112
|
+
failed.insert(
|
|
113
|
+
"reason".to_string(),
|
|
114
|
+
serde_json::json!("tmux window not present after spawn"),
|
|
115
|
+
);
|
|
116
|
+
agents.insert(id.to_string(), serde_json::Value::Object(failed));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
let pane_pid = pane_pids_by_agent.get(id).copied();
|
|
120
|
+
let spawned_at = started_agent
|
|
121
|
+
.map(|started| started.spawned_at.clone())
|
|
122
|
+
.unwrap_or_else(|| spawn_timestamp_for_agent(spawn_index));
|
|
123
|
+
spawn_index = spawn_index.saturating_add(1);
|
|
124
|
+
agents.insert(
|
|
125
|
+
id.to_string(),
|
|
126
|
+
running_agent_state(
|
|
127
|
+
agent,
|
|
128
|
+
id,
|
|
129
|
+
provider,
|
|
130
|
+
workspace,
|
|
131
|
+
workspace,
|
|
132
|
+
&spawned_at,
|
|
133
|
+
&team_id,
|
|
134
|
+
Some(agent_id_to_pane_id(started, id)),
|
|
135
|
+
pane_pid,
|
|
136
|
+
safety,
|
|
137
|
+
started_agent,
|
|
138
|
+
Some(&profile_dir),
|
|
139
|
+
)?,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if let Some(obj) = state.as_object_mut() {
|
|
143
|
+
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
144
|
+
} else {
|
|
145
|
+
let mut obj = serde_json::Map::new();
|
|
146
|
+
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
147
|
+
state = serde_json::Value::Object(obj);
|
|
148
|
+
}
|
|
149
|
+
save_launched_team_state_for_key(workspace, &state, Some(&team_id))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pub(super) fn pane_pids_by_started_agent(
|
|
153
|
+
transport: &dyn Transport,
|
|
154
|
+
started: &[StartedAgent],
|
|
155
|
+
) -> BTreeMap<String, u32> {
|
|
156
|
+
let panes = transport.list_targets().unwrap_or_default();
|
|
157
|
+
started
|
|
158
|
+
.iter()
|
|
159
|
+
.filter_map(|agent| {
|
|
160
|
+
panes
|
|
161
|
+
.iter()
|
|
162
|
+
.find(|pane| pane.pane_id.as_str() == agent.target)
|
|
163
|
+
.and_then(|pane| pane.pane_pid)
|
|
164
|
+
.map(|pid| (agent.agent_id.as_str().to_string(), pid))
|
|
165
|
+
})
|
|
166
|
+
.collect()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
pub(super) fn agent_id_to_pane_id<'a>(started: &'a [StartedAgent], agent_id: &str) -> &'a str {
|
|
170
|
+
started
|
|
171
|
+
.iter()
|
|
172
|
+
.find(|agent| agent.agent_id.as_str() == agent_id)
|
|
173
|
+
.map(|agent| agent.target.as_str())
|
|
174
|
+
.unwrap_or("")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
pub(super) fn save_launched_team_state(
|
|
178
|
+
workspace: &Path,
|
|
179
|
+
launched: &serde_json::Value,
|
|
180
|
+
) -> Result<(), LifecycleError> {
|
|
181
|
+
save_launched_team_state_for_key(workspace, launched, None)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
pub(super) fn save_launched_team_state_for_key(
|
|
185
|
+
workspace: &Path,
|
|
186
|
+
launched: &serde_json::Value,
|
|
187
|
+
team_key: Option<&str>,
|
|
188
|
+
) -> Result<(), LifecycleError> {
|
|
189
|
+
let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
|
|
190
|
+
let launched_key = team_key
|
|
191
|
+
.filter(|key| !key.is_empty())
|
|
192
|
+
.map(str::to_string)
|
|
193
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(launched));
|
|
194
|
+
let mut launched = launched.clone();
|
|
195
|
+
if let Some(obj) = launched.as_object_mut() {
|
|
196
|
+
// RM-039-STAT-001 second-round fix (architect verdict
|
|
197
|
+
// 2026-06-22): the canonical runtime team key MUST be written
|
|
198
|
+
// explicitly to `state.team_key`, not only inferred from
|
|
199
|
+
// `team_dir` by `team_state_key`'s cascade. The historical
|
|
200
|
+
// bug: when `team_dir = "./.team/current"` and the runtime team
|
|
201
|
+
// is `rm039-status-working-891`, `team_state_key` cascades to
|
|
202
|
+
// the team_dir basename (`current`), but `active_team_key` is
|
|
203
|
+
// the real team key. Coordinator tick uses `team_state_key`
|
|
204
|
+
// when saving the team-scoped state, so writes land on
|
|
205
|
+
// `teams.current` instead of `teams.<active_team_key>`. Status
|
|
206
|
+
// reads `teams[active_team_key]`, sees stale data.
|
|
207
|
+
//
|
|
208
|
+
// Writing `team_key=launched_key` here pins the first branch of
|
|
209
|
+
// `team_state_key` so the cascade returns the canonical runtime
|
|
210
|
+
// team key everywhere — coordinator tick, save_team_scoped_state,
|
|
211
|
+
// and status selector all agree.
|
|
212
|
+
obj.insert(
|
|
213
|
+
"team_key".to_string(),
|
|
214
|
+
serde_json::Value::String(launched_key.clone()),
|
|
215
|
+
);
|
|
216
|
+
obj.insert(
|
|
217
|
+
"active_team_key".to_string(),
|
|
218
|
+
serde_json::Value::String(launched_key.clone()),
|
|
219
|
+
);
|
|
220
|
+
obj.entry("is_external_leader".to_string())
|
|
221
|
+
.or_insert(serde_json::Value::Bool(false));
|
|
222
|
+
}
|
|
223
|
+
promote_launched_binding_from_team_entry(&mut launched, &launched_key);
|
|
224
|
+
preserve_existing_leader_topology(&existing, &launched_key, &mut launched);
|
|
225
|
+
drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
|
|
226
|
+
drop_bare_worker_seeded_owner(&mut launched, &launched_key);
|
|
227
|
+
let merged = if team_key.is_some() {
|
|
228
|
+
merge_workspace_team_state_with_key(&existing, &launched, &launched_key)
|
|
229
|
+
} else {
|
|
230
|
+
crate::state::projection::merge_workspace_team_state(&existing, &launched)
|
|
231
|
+
};
|
|
232
|
+
let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
|
|
233
|
+
drop_unbound_top_level_owner(&mut projected);
|
|
234
|
+
crate::state::repository::StateRepository::new(workspace)
|
|
235
|
+
.save(
|
|
236
|
+
crate::state::repository::StateWriteIntent::LaunchTeam {
|
|
237
|
+
team_key: &launched_key,
|
|
238
|
+
},
|
|
239
|
+
&projected,
|
|
240
|
+
)
|
|
241
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub(super) fn preserve_existing_leader_topology(
|
|
245
|
+
existing: &serde_json::Value,
|
|
246
|
+
launched_key: &str,
|
|
247
|
+
launched: &mut serde_json::Value,
|
|
248
|
+
) {
|
|
249
|
+
let Some(obj) = launched.as_object_mut() else {
|
|
250
|
+
return;
|
|
251
|
+
};
|
|
252
|
+
let existing_team = existing
|
|
253
|
+
.get("teams")
|
|
254
|
+
.and_then(serde_json::Value::as_object)
|
|
255
|
+
.and_then(|teams| teams.get(launched_key))
|
|
256
|
+
.unwrap_or(existing);
|
|
257
|
+
for key in ["is_external_leader", "leader_client"] {
|
|
258
|
+
if !obj.contains_key(key) {
|
|
259
|
+
if let Some(value) = existing_team.get(key).or_else(|| existing.get(key)) {
|
|
260
|
+
obj.insert(key.to_string(), value.clone());
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
pub(super) fn drop_bare_worker_seeded_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
267
|
+
if has_positive_caller_leader_env() {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
let pane = launched
|
|
271
|
+
.get("team_owner")
|
|
272
|
+
.and_then(|owner| owner.get("pane_id"))
|
|
273
|
+
.and_then(serde_json::Value::as_str)
|
|
274
|
+
.unwrap_or("");
|
|
275
|
+
if pane.ends_with("-first") {
|
|
276
|
+
seed_unbound_launched_owner(launched, launched_key);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
pub(super) fn merge_workspace_team_state_with_key(
|
|
281
|
+
existing: &serde_json::Value,
|
|
282
|
+
launched: &serde_json::Value,
|
|
283
|
+
launched_key: &str,
|
|
284
|
+
) -> serde_json::Value {
|
|
285
|
+
let mut launched_obj = launched.as_object().cloned().unwrap_or_default();
|
|
286
|
+
let mut teams = existing
|
|
287
|
+
.get("teams")
|
|
288
|
+
.and_then(serde_json::Value::as_object)
|
|
289
|
+
.cloned()
|
|
290
|
+
.unwrap_or_default();
|
|
291
|
+
let launched_entry = crate::state::projection::compact_team_state(launched);
|
|
292
|
+
if !existing
|
|
293
|
+
.get("session_name")
|
|
294
|
+
.and_then(serde_json::Value::as_str)
|
|
295
|
+
.is_some_and(|session| !session.is_empty())
|
|
296
|
+
{
|
|
297
|
+
teams.insert(launched_key.to_string(), launched_entry);
|
|
298
|
+
launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
|
|
299
|
+
return serde_json::Value::Object(launched_obj);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let existing_key = explicit_active_team_key(existing)
|
|
303
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(existing));
|
|
304
|
+
if existing_key == launched_key {
|
|
305
|
+
let mut teams = existing
|
|
306
|
+
.get("teams")
|
|
307
|
+
.and_then(serde_json::Value::as_object)
|
|
308
|
+
.cloned()
|
|
309
|
+
.unwrap_or_default();
|
|
310
|
+
teams.insert(launched_key.to_string(), launched_entry);
|
|
311
|
+
launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
|
|
312
|
+
return serde_json::Value::Object(launched_obj);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let mut merged = existing.as_object().cloned().unwrap_or_default();
|
|
316
|
+
let mut teams = merged
|
|
317
|
+
.get("teams")
|
|
318
|
+
.and_then(serde_json::Value::as_object)
|
|
319
|
+
.cloned()
|
|
320
|
+
.unwrap_or_default();
|
|
321
|
+
teams
|
|
322
|
+
.entry(existing_key)
|
|
323
|
+
.or_insert_with(|| crate::state::projection::compact_team_state(existing));
|
|
324
|
+
teams.insert(launched_key.to_string(), launched_entry);
|
|
325
|
+
merged.insert("teams".to_string(), serde_json::Value::Object(teams));
|
|
326
|
+
serde_json::Value::Object(merged)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[cfg(test)]
|
|
330
|
+
mod merge_workspace_team_state_with_key_tests {
|
|
331
|
+
use super::*;
|
|
332
|
+
use serde_json::json;
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn empty_top_level_existing_session_preserves_existing_teams() {
|
|
336
|
+
let existing = json!({
|
|
337
|
+
"session_name": "",
|
|
338
|
+
"active_team_key": "parent",
|
|
339
|
+
"teams": {
|
|
340
|
+
"parent": {
|
|
341
|
+
"session_name": "team-parent",
|
|
342
|
+
"agents": {"parent_worker": {"status": "running"}}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
let launched = json!({
|
|
347
|
+
"session_name": "team-child",
|
|
348
|
+
"agents": {"child_worker": {"status": "running"}}
|
|
349
|
+
});
|
|
350
|
+
let merged = merge_workspace_team_state_with_key(&existing, &launched, "child");
|
|
351
|
+
assert_eq!(
|
|
352
|
+
merged.pointer("/teams/parent/session_name"),
|
|
353
|
+
Some(&json!("team-parent")),
|
|
354
|
+
"existing.teams must survive even when existing.session_name is empty: {merged}"
|
|
355
|
+
);
|
|
356
|
+
assert_eq!(
|
|
357
|
+
merged.pointer("/teams/child/session_name"),
|
|
358
|
+
Some(&json!("team-child")),
|
|
359
|
+
"launched team must still be inserted under its runtime key: {merged}"
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
pub(super) fn promote_launched_binding_from_team_entry(
|
|
365
|
+
launched: &mut serde_json::Value,
|
|
366
|
+
launched_key: &str,
|
|
367
|
+
) {
|
|
368
|
+
let entry = launched
|
|
369
|
+
.get("teams")
|
|
370
|
+
.and_then(|teams| teams.get(launched_key))
|
|
371
|
+
.cloned();
|
|
372
|
+
let Some(entry) = entry else {
|
|
373
|
+
return;
|
|
374
|
+
};
|
|
375
|
+
let Some(obj) = launched.as_object_mut() else {
|
|
376
|
+
return;
|
|
377
|
+
};
|
|
378
|
+
for key in ["leader_receiver", "team_owner", "owner_epoch"] {
|
|
379
|
+
if !obj.contains_key(key) {
|
|
380
|
+
if let Some(value) = entry.get(key) {
|
|
381
|
+
obj.insert(key.to_string(), value.clone());
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
pub(super) fn drop_unbound_top_level_owner(state: &mut serde_json::Value) {
|
|
388
|
+
let pane = state
|
|
389
|
+
.get("team_owner")
|
|
390
|
+
.and_then(|owner| owner.get("pane_id"))
|
|
391
|
+
.and_then(serde_json::Value::as_str)
|
|
392
|
+
.unwrap_or("");
|
|
393
|
+
if pane.starts_with('%') || pane.chars().all(|ch| ch.is_ascii_digit()) && !pane.is_empty() {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if let Some(obj) = state.as_object_mut() {
|
|
397
|
+
obj.remove("leader_receiver");
|
|
398
|
+
obj.remove("team_owner");
|
|
399
|
+
obj.remove("owner_epoch");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
pub(super) fn drop_foreign_seeded_owner(
|
|
404
|
+
existing: &serde_json::Value,
|
|
405
|
+
launched_key: &str,
|
|
406
|
+
launched: &mut serde_json::Value,
|
|
407
|
+
) {
|
|
408
|
+
let Some(pane) = launched
|
|
409
|
+
.get("team_owner")
|
|
410
|
+
.and_then(|owner| owner.get("pane_id"))
|
|
411
|
+
.and_then(serde_json::Value::as_str)
|
|
412
|
+
.filter(|pane| !pane.is_empty())
|
|
413
|
+
else {
|
|
414
|
+
return;
|
|
415
|
+
};
|
|
416
|
+
if owner_pane_belongs_to_other_team(existing, launched_key, pane) {
|
|
417
|
+
let replacement = unbound_launched_owner(launched, launched_key);
|
|
418
|
+
// Stage 3a (identity-boundary unified plan, architect direction
|
|
419
|
+
// 2026-06-23): route owner replace through ownership repository.
|
|
420
|
+
// Both branches (Some owner replacement; None = removal) preserve
|
|
421
|
+
// pre-3a behaviour: write_owner overwrites the top-level fields
|
|
422
|
+
// when given; the None branch falls back to direct mutation (the
|
|
423
|
+
// repository has no remove API yet — removal is uncommon enough
|
|
424
|
+
// to leave it inline).
|
|
425
|
+
if let Some(owner) = replacement {
|
|
426
|
+
let record = crate::state::ownership::OwnershipWrite::new().with_team_owner(owner);
|
|
427
|
+
crate::state::ownership::write_owner(launched, launched_key, record);
|
|
428
|
+
if let Some(obj) = launched.as_object_mut() {
|
|
429
|
+
obj.remove("owner_epoch");
|
|
430
|
+
}
|
|
431
|
+
} else if let Some(obj) = launched.as_object_mut() {
|
|
432
|
+
obj.remove("team_owner");
|
|
433
|
+
obj.remove("owner_epoch");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
pub(super) fn drop_worker_pane_seeded_owner(
|
|
439
|
+
launched: &mut serde_json::Value,
|
|
440
|
+
launched_key: &str,
|
|
441
|
+
started: &[StartedAgent],
|
|
442
|
+
worker_tmux_socket: Option<&str>,
|
|
443
|
+
) {
|
|
444
|
+
let Some(pane) = launched
|
|
445
|
+
.get("team_owner")
|
|
446
|
+
.and_then(|owner| owner.get("pane_id"))
|
|
447
|
+
.and_then(serde_json::Value::as_str)
|
|
448
|
+
.filter(|pane| !pane.is_empty())
|
|
449
|
+
else {
|
|
450
|
+
return;
|
|
451
|
+
};
|
|
452
|
+
let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
|
|
453
|
+
.ok()
|
|
454
|
+
.filter(|value| !value.is_empty());
|
|
455
|
+
let tmux_pane = std::env::var("TMUX_PANE")
|
|
456
|
+
.ok()
|
|
457
|
+
.filter(|value| !value.is_empty());
|
|
458
|
+
let has_leader_identity_env = has_positive_caller_leader_env();
|
|
459
|
+
let seeded_from_bare_tmux = !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
|
|
460
|
+
let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
|
|
461
|
+
if seeded_from_bare_tmux
|
|
462
|
+
&& (tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
|
|
463
|
+
|| pane.ends_with("-first"))
|
|
464
|
+
&& seeded_pane_looks_like_worker(pane, started)
|
|
465
|
+
{
|
|
466
|
+
seed_unbound_launched_owner(launched, launched_key);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
pub(super) fn seeded_pane_looks_like_worker(pane: &str, started: &[StartedAgent]) -> bool {
|
|
471
|
+
pane.ends_with("-first")
|
|
472
|
+
|| started.iter().any(|agent| {
|
|
473
|
+
pane == agent.target
|
|
474
|
+
|| pane.starts_with(agent.target.as_str())
|
|
475
|
+
|| agent.target.starts_with(pane)
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
pub(super) fn launched_worker_tmux_socket(
|
|
480
|
+
transport: &dyn Transport,
|
|
481
|
+
workspace: &Path,
|
|
482
|
+
) -> Option<String> {
|
|
483
|
+
if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
|
|
484
|
+
Some(crate::tmux_backend::socket_name_for_workspace(workspace))
|
|
485
|
+
} else {
|
|
486
|
+
None
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
pub(super) fn tmux_sockets_match_or_unknown(
|
|
491
|
+
caller_socket: Option<&str>,
|
|
492
|
+
worker_socket: Option<&str>,
|
|
493
|
+
) -> bool {
|
|
494
|
+
match (caller_socket, worker_socket) {
|
|
495
|
+
(Some(caller), Some(worker)) => caller == worker,
|
|
496
|
+
(Some(_), None) => false,
|
|
497
|
+
(None, _) => true,
|
|
498
|
+
}
|
|
499
|
+
}
|