@team-agent/installer 0.3.14 → 0.3.16
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 +1 -0
- package/crates/team-agent/src/cli/emit.rs +92 -11
- package/crates/team-agent/src/cli/leader.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +255 -37
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/status.rs +5 -1
- package/crates/team-agent/src/cli/status_port.rs +24 -0
- package/crates/team-agent/src/cli/tests/base.rs +21 -3
- package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
- package/crates/team-agent/src/cli/types.rs +32 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/fake_worker.rs +32 -145
- package/crates/team-agent/src/leader/start.rs +272 -8
- package/crates/team-agent/src/leader/tests/basics.rs +1 -0
- package/crates/team-agent/src/leader/tests/identity.rs +121 -25
- package/crates/team-agent/src/leader/types.rs +9 -0
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +631 -70
- package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
- package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
- package/crates/team-agent/src/lifecycle/types.rs +5 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +153 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
- package/crates/team-agent/src/messaging/mod.rs +6 -3
- package/crates/team-agent/src/messaging/results.rs +240 -158
- package/crates/team-agent/src/messaging/send.rs +3 -2
- package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
- package/crates/team-agent/src/os_probe.rs +11 -0
- package/crates/team-agent/src/state/persist.rs +6 -0
- package/crates/team-agent/src/tmux_backend.rs +85 -0
- package/crates/team-agent/src/transport/test_support.rs +46 -1
- package/crates/team-agent/src/transport.rs +27 -0
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +277 -0
|
@@ -107,7 +107,10 @@ fn parse_rendered_header(line: &str) -> Option<RenderedBlock> {
|
|
|
107
107
|
.split_once(" for ")
|
|
108
108
|
.map(|(_, task)| task.trim().to_string())
|
|
109
109
|
.filter(|task| !task.is_empty());
|
|
110
|
-
Some(RenderedBlock {
|
|
110
|
+
Some(RenderedBlock {
|
|
111
|
+
task_id,
|
|
112
|
+
lines: Vec::new(),
|
|
113
|
+
})
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
fn parse_token(line: &str) -> Option<String> {
|
|
@@ -135,149 +138,13 @@ fn report_fake_result(
|
|
|
135
138
|
.ok()
|
|
136
139
|
.map(|value| value.trim().to_string())
|
|
137
140
|
.filter(|value| !value.is_empty());
|
|
138
|
-
|
|
141
|
+
let _ = source_content;
|
|
139
142
|
crate::messaging::report_result_for_owner_team(workspace, &envelope, owner_team.as_deref())
|
|
140
143
|
.map_err(|e| FakeWorkerError::Report(e.to_string()))?;
|
|
141
|
-
mirror_fake_result_to_leader(workspace, owner_team.as_deref(), &envelope, source_content);
|
|
142
144
|
writeln!(output, "{}", serde_json::to_string(&envelope)?)?;
|
|
143
145
|
Ok(())
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
fn mirror_fake_result_to_leader(
|
|
147
|
-
workspace: &Path,
|
|
148
|
-
owner_team: Option<&str>,
|
|
149
|
-
envelope: &serde_json::Value,
|
|
150
|
-
source_content: Option<&str>,
|
|
151
|
-
) {
|
|
152
|
-
let Ok(raw_state) = crate::state::persist::load_runtime_state(workspace) else {
|
|
153
|
-
return;
|
|
154
|
-
};
|
|
155
|
-
let state = owner_team
|
|
156
|
-
.and_then(|team| crate::state::projection::resolve_owner_team_id(&raw_state, team).canonical_key().map(str::to_string))
|
|
157
|
-
.map(|team| crate::state::projection::project_top_level_view(&raw_state, &team))
|
|
158
|
-
.unwrap_or(raw_state);
|
|
159
|
-
let attached = state
|
|
160
|
-
.get("leader_receiver")
|
|
161
|
-
.and_then(|receiver| receiver.get("status"))
|
|
162
|
-
.and_then(serde_json::Value::as_str)
|
|
163
|
-
== Some("attached");
|
|
164
|
-
let pane_id = state
|
|
165
|
-
.get("leader_receiver")
|
|
166
|
-
.and_then(|receiver| receiver.get("pane_id"))
|
|
167
|
-
.and_then(serde_json::Value::as_str)
|
|
168
|
-
.filter(|pane| !pane.is_empty())
|
|
169
|
-
.map(str::to_string)
|
|
170
|
-
.or_else(|| owner_team.and_then(|team| claimed_pane_from_events(workspace, team)));
|
|
171
|
-
let Some(pane_id) = pane_id.filter(|_| attached || owner_team.is_some()) else {
|
|
172
|
-
return;
|
|
173
|
-
};
|
|
174
|
-
let summary = envelope
|
|
175
|
-
.get("summary")
|
|
176
|
-
.and_then(serde_json::Value::as_str)
|
|
177
|
-
.unwrap_or("Fake worker completed");
|
|
178
|
-
let result_id = envelope
|
|
179
|
-
.get("result_id")
|
|
180
|
-
.and_then(serde_json::Value::as_str)
|
|
181
|
-
.unwrap_or("");
|
|
182
|
-
let text = match source_content.filter(|content| !content.trim().is_empty()) {
|
|
183
|
-
Some(content) => format!(
|
|
184
|
-
"Fake worker result: {summary}\nMessage content: {}\nResult id: {result_id}",
|
|
185
|
-
content.trim()
|
|
186
|
-
),
|
|
187
|
-
None => format!("Fake worker result: {summary}\nResult id: {result_id}"),
|
|
188
|
-
};
|
|
189
|
-
let target = crate::transport::Target::Pane(crate::transport::PaneId::new(&pane_id));
|
|
190
|
-
let payload = crate::transport::InjectPayload::Text(text.clone());
|
|
191
|
-
let key = crate::transport::Key::Enter;
|
|
192
|
-
if let Some(socket) = leader_receiver_full_socket(&state) {
|
|
193
|
-
let endpoint_backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
|
|
194
|
-
if crate::transport::Transport::inject(&endpoint_backend, &target, &payload, key, true).is_ok() {
|
|
195
|
-
mirror_to_team_session_panes(&state, workspace, agent_id_from_envelope(envelope), &text);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
let workspace_backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
200
|
-
let _ = crate::transport::Transport::inject(&workspace_backend, &target, &payload, key, true);
|
|
201
|
-
mirror_to_team_session_panes(&state, workspace, agent_id_from_envelope(envelope), &text);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
fn leader_receiver_full_socket(state: &serde_json::Value) -> Option<&str> {
|
|
205
|
-
state
|
|
206
|
-
.get("leader_receiver")
|
|
207
|
-
.and_then(|receiver| receiver.get("tmux_socket"))
|
|
208
|
-
.and_then(serde_json::Value::as_str)
|
|
209
|
-
.filter(|socket| !socket.is_empty() && Path::new(socket).is_absolute())
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
fn claimed_pane_from_events(workspace: &Path, owner_team: &str) -> Option<String> {
|
|
213
|
-
crate::event_log::EventLog::new(workspace)
|
|
214
|
-
.tail(50)
|
|
215
|
-
.ok()?
|
|
216
|
-
.into_iter()
|
|
217
|
-
.rev()
|
|
218
|
-
.find_map(|event| {
|
|
219
|
-
if event.get("event").and_then(serde_json::Value::as_str)
|
|
220
|
-
!= Some("leader_receiver.rebind_applied")
|
|
221
|
-
{
|
|
222
|
-
return None;
|
|
223
|
-
}
|
|
224
|
-
if event.get("team_id").and_then(serde_json::Value::as_str) != Some(owner_team) {
|
|
225
|
-
return None;
|
|
226
|
-
}
|
|
227
|
-
event
|
|
228
|
-
.get("new_pane_id")
|
|
229
|
-
.and_then(serde_json::Value::as_str)
|
|
230
|
-
.filter(|pane| !pane.is_empty())
|
|
231
|
-
.map(str::to_string)
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
fn agent_id_from_envelope(envelope: &serde_json::Value) -> Option<&str> {
|
|
236
|
-
envelope.get("agent_id").and_then(serde_json::Value::as_str)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
fn mirror_to_team_session_panes(
|
|
240
|
-
state: &serde_json::Value,
|
|
241
|
-
workspace: &Path,
|
|
242
|
-
agent_id: Option<&str>,
|
|
243
|
-
text: &str,
|
|
244
|
-
) {
|
|
245
|
-
let Some(session) = state
|
|
246
|
-
.get("session_name")
|
|
247
|
-
.and_then(serde_json::Value::as_str)
|
|
248
|
-
.filter(|session| !session.is_empty())
|
|
249
|
-
else {
|
|
250
|
-
return;
|
|
251
|
-
};
|
|
252
|
-
let worker_pane = agent_id.and_then(|agent_id| {
|
|
253
|
-
state
|
|
254
|
-
.get("agents")
|
|
255
|
-
.and_then(|agents| agents.get(agent_id))
|
|
256
|
-
.and_then(|agent| agent.get("pane_id"))
|
|
257
|
-
.and_then(serde_json::Value::as_str)
|
|
258
|
-
});
|
|
259
|
-
if let Some(socket) = leader_receiver_full_socket(state) {
|
|
260
|
-
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
|
|
261
|
-
for pane in crate::transport::Transport::list_targets(&backend).unwrap_or_default() {
|
|
262
|
-
if pane.session.as_str() != session || Some(pane.pane_id.as_str()) == worker_pane {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
let target = crate::transport::Target::Pane(pane.pane_id);
|
|
266
|
-
let payload = crate::transport::InjectPayload::Text(text.to_string());
|
|
267
|
-
let _ = crate::transport::Transport::inject(&backend, &target, &payload, crate::transport::Key::Enter, true);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
let backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
271
|
-
for pane in crate::transport::Transport::list_targets(&backend).unwrap_or_default() {
|
|
272
|
-
if pane.session.as_str() != session || Some(pane.pane_id.as_str()) == worker_pane {
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
let target = crate::transport::Target::Pane(pane.pane_id);
|
|
276
|
-
let payload = crate::transport::InjectPayload::Text(text.to_string());
|
|
277
|
-
let _ = crate::transport::Transport::inject(&backend, &target, &payload, crate::transport::Key::Enter, true);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
148
|
fn fake_envelope(
|
|
282
149
|
workspace: &Path,
|
|
283
150
|
agent_id: &str,
|
|
@@ -331,7 +198,9 @@ mod tests {
|
|
|
331
198
|
fn stored_result(ws: &std::path::Path) -> (String, String, String, serde_json::Value) {
|
|
332
199
|
let store = MessageStore::open(ws).unwrap();
|
|
333
200
|
let conn = open_db(store.db_path()).unwrap();
|
|
334
|
-
let mut stmt = conn
|
|
201
|
+
let mut stmt = conn
|
|
202
|
+
.prepare("select task_id, agent_id, status, envelope from results")
|
|
203
|
+
.unwrap();
|
|
335
204
|
let row = stmt
|
|
336
205
|
.query_row([], |r| {
|
|
337
206
|
Ok((
|
|
@@ -354,9 +223,18 @@ mod tests {
|
|
|
354
223
|
("t1", "w1", "success"),
|
|
355
224
|
"stored result row must be task_id=t1 agent_id=w1 status=success"
|
|
356
225
|
);
|
|
357
|
-
assert_eq!(
|
|
358
|
-
|
|
359
|
-
|
|
226
|
+
assert_eq!(
|
|
227
|
+
env["schema_version"],
|
|
228
|
+
serde_json::json!("result_envelope_v1")
|
|
229
|
+
);
|
|
230
|
+
assert_eq!(
|
|
231
|
+
env["summary"],
|
|
232
|
+
serde_json::json!("Fake worker handled message m1")
|
|
233
|
+
);
|
|
234
|
+
assert_eq!(
|
|
235
|
+
env["tests"],
|
|
236
|
+
serde_json::json!([{"command": "fake-provider", "status": "passed"}])
|
|
237
|
+
);
|
|
360
238
|
let artifact_path = env["artifacts"][0]["path"].as_str().unwrap_or_default();
|
|
361
239
|
assert!(
|
|
362
240
|
artifact_path.ends_with(".team/logs/w1.scrollback"),
|
|
@@ -375,8 +253,14 @@ mod tests {
|
|
|
375
253
|
|
|
376
254
|
assert_golden_envelope(&ws);
|
|
377
255
|
let printed = String::from_utf8(out).unwrap();
|
|
378
|
-
assert!(
|
|
379
|
-
|
|
256
|
+
assert!(
|
|
257
|
+
printed.contains("TEAM_AGENT_FAKE_READY agent=w1"),
|
|
258
|
+
"must print the READY idle marker; got {printed}"
|
|
259
|
+
);
|
|
260
|
+
assert!(
|
|
261
|
+
printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"),
|
|
262
|
+
"must print the WORKING marker on a non-empty line; got {printed}"
|
|
263
|
+
);
|
|
380
264
|
}
|
|
381
265
|
|
|
382
266
|
// Rendered-message form: a `Team Agent message from leader for t1:` block ending `[team-agent-token:m1]`
|
|
@@ -391,6 +275,9 @@ mod tests {
|
|
|
391
275
|
|
|
392
276
|
assert_golden_envelope(&ws);
|
|
393
277
|
let printed = String::from_utf8(out).unwrap();
|
|
394
|
-
assert!(
|
|
278
|
+
assert!(
|
|
279
|
+
printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"),
|
|
280
|
+
"rendered form prints WORKING per line; got {printed}"
|
|
281
|
+
);
|
|
395
282
|
}
|
|
396
283
|
}
|
|
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
|
|
7
7
|
|
|
8
8
|
use crate::provider::{get_adapter, Provider};
|
|
9
9
|
use crate::tmux_backend::TmuxBackend;
|
|
10
|
-
use crate::transport::{PaneId, SessionName, Target, Transport};
|
|
10
|
+
use crate::transport::{PaneId, SessionName, SpawnResult, Target, Transport, WindowName};
|
|
11
11
|
|
|
12
12
|
use super::helpers::{
|
|
13
13
|
provider_wire, resolve_workspace_for_hash, sanitize_session_folder, sha1_hex_prefix,
|
|
@@ -29,6 +29,7 @@ pub fn leader_start_plan(
|
|
|
29
29
|
attach_existing: bool,
|
|
30
30
|
confirm_attach: bool,
|
|
31
31
|
attach_session: Option<&SessionName>,
|
|
32
|
+
external_leader: bool,
|
|
32
33
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
33
34
|
if attach_session.is_some() && !confirm_attach {
|
|
34
35
|
return Err(LeaderError::Start(
|
|
@@ -49,15 +50,24 @@ pub fn leader_start_plan(
|
|
|
49
50
|
command_name
|
|
50
51
|
)));
|
|
51
52
|
}
|
|
52
|
-
let
|
|
53
|
-
let
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
let state = crate::state::persist::load_runtime_state(workspace).ok();
|
|
54
|
+
let identity = leader_identity_context(workspace, None, state.as_ref())?;
|
|
55
|
+
let external_path = external_leader || attach_existing || attach_session.is_some();
|
|
56
|
+
let session_name = if external_path {
|
|
57
|
+
attach_session
|
|
58
|
+
.cloned()
|
|
59
|
+
.or_else(|| Some(leader_session_name(provider, workspace)))
|
|
60
|
+
} else {
|
|
61
|
+
Some(managed_team_session_name(&identity))
|
|
62
|
+
};
|
|
56
63
|
let in_tmux = std::env::var_os("TMUX").is_some();
|
|
57
|
-
if !in_tmux {
|
|
64
|
+
if !in_tmux || !external_path {
|
|
58
65
|
ensure_tmux_installed()?;
|
|
59
66
|
}
|
|
60
|
-
|
|
67
|
+
if !external_path {
|
|
68
|
+
validate_managed_tmux_context(workspace)?;
|
|
69
|
+
}
|
|
70
|
+
let existing_session = if external_path && !in_tmux && !attach_existing && attach_session.is_none() {
|
|
61
71
|
match session_name.as_ref() {
|
|
62
72
|
Some(session) => tmux_session_exists(workspace, session)?,
|
|
63
73
|
None => false,
|
|
@@ -65,7 +75,9 @@ pub fn leader_start_plan(
|
|
|
65
75
|
} else {
|
|
66
76
|
false
|
|
67
77
|
};
|
|
68
|
-
let mode = if
|
|
78
|
+
let mode = if !external_path {
|
|
79
|
+
LeaderStartMode::ManagedTmuxClient
|
|
80
|
+
} else if in_tmux {
|
|
69
81
|
LeaderStartMode::ExecProvider
|
|
70
82
|
} else if attach_existing || attach_session.is_some() || existing_session {
|
|
71
83
|
LeaderStartMode::AttachExisting
|
|
@@ -86,6 +98,7 @@ pub fn leader_start_plan(
|
|
|
86
98
|
} else {
|
|
87
99
|
leader_env.clone()
|
|
88
100
|
};
|
|
101
|
+
let provider_argv = provider_command_argv(provider, provider_args);
|
|
89
102
|
Ok(LeaderStartPlan {
|
|
90
103
|
mode,
|
|
91
104
|
provider,
|
|
@@ -93,6 +106,10 @@ pub fn leader_start_plan(
|
|
|
93
106
|
socket: LeaderLaunchSocket::Workspace,
|
|
94
107
|
session_name,
|
|
95
108
|
argv,
|
|
109
|
+
provider_argv,
|
|
110
|
+
leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
|
|
111
|
+
.then(|| WindowName::new("leader")),
|
|
112
|
+
is_external_leader: external_path,
|
|
96
113
|
leader_env: plan_env,
|
|
97
114
|
identity: Some(identity),
|
|
98
115
|
detached: false,
|
|
@@ -142,6 +159,7 @@ pub fn start_leader(
|
|
|
142
159
|
attach_existing: bool,
|
|
143
160
|
confirm_attach: bool,
|
|
144
161
|
attach_session: Option<&SessionName>,
|
|
162
|
+
external_leader: bool,
|
|
145
163
|
) -> Result<(), LeaderError> {
|
|
146
164
|
let plan = leader_start_plan(
|
|
147
165
|
provider,
|
|
@@ -150,6 +168,7 @@ pub fn start_leader(
|
|
|
150
168
|
attach_existing,
|
|
151
169
|
confirm_attach,
|
|
152
170
|
attach_session,
|
|
171
|
+
external_leader,
|
|
153
172
|
)?;
|
|
154
173
|
crate::event_log::EventLog::new(workspace).write(
|
|
155
174
|
super::LeaderEvent::LeaderStart.name(),
|
|
@@ -170,6 +189,9 @@ pub fn execute_leader_plan(
|
|
|
170
189
|
plan: &LeaderStartPlan,
|
|
171
190
|
workspace: &Path,
|
|
172
191
|
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
192
|
+
if plan.mode == LeaderStartMode::ManagedTmuxClient {
|
|
193
|
+
return execute_managed_leader_plan(plan, workspace);
|
|
194
|
+
}
|
|
173
195
|
let mut argv = plan.argv.clone();
|
|
174
196
|
let detached = plan.mode == LeaderStartMode::NewTmuxSession
|
|
175
197
|
&& !std::io::stdin().is_terminal()
|
|
@@ -236,6 +258,12 @@ fn start_argv(
|
|
|
236
258
|
argv.extend(provider_args.iter().cloned());
|
|
237
259
|
Ok(argv)
|
|
238
260
|
}
|
|
261
|
+
LeaderStartMode::ManagedTmuxClient => {
|
|
262
|
+
let Some(session) = session_name else {
|
|
263
|
+
return Err(LeaderError::Start("managed leader session missing".to_string()));
|
|
264
|
+
};
|
|
265
|
+
managed_client_argv(workspace, session)
|
|
266
|
+
}
|
|
239
267
|
LeaderStartMode::AttachExisting => {
|
|
240
268
|
let Some(session) = session_name else {
|
|
241
269
|
return Err(LeaderError::Start("attach session missing".to_string()));
|
|
@@ -283,6 +311,224 @@ fn start_argv(
|
|
|
283
311
|
}
|
|
284
312
|
}
|
|
285
313
|
|
|
314
|
+
fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
|
|
315
|
+
let mut argv = vec![provider_command_name(provider).to_string()];
|
|
316
|
+
argv.extend(provider_args.iter().cloned());
|
|
317
|
+
argv
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
|
|
321
|
+
let team = identity.team_id.as_str();
|
|
322
|
+
if team.starts_with("team-") {
|
|
323
|
+
SessionName::new(team.to_string())
|
|
324
|
+
} else {
|
|
325
|
+
SessionName::new(format!("team-{team}"))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fn managed_client_argv(workspace: &Path, session: &SessionName) -> Result<Vec<String>, LeaderError> {
|
|
330
|
+
let target = format!("{}:leader", session.as_str());
|
|
331
|
+
let argv = if std::env::var_os("TMUX").is_some() {
|
|
332
|
+
vec![
|
|
333
|
+
"tmux".to_string(),
|
|
334
|
+
"switch-client".to_string(),
|
|
335
|
+
"-t".to_string(),
|
|
336
|
+
target,
|
|
337
|
+
]
|
|
338
|
+
} else {
|
|
339
|
+
vec![
|
|
340
|
+
"tmux".to_string(),
|
|
341
|
+
"attach-session".to_string(),
|
|
342
|
+
"-t".to_string(),
|
|
343
|
+
target,
|
|
344
|
+
]
|
|
345
|
+
};
|
|
346
|
+
Ok(TmuxBackend::argv_for_workspace(workspace, &argv))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn validate_managed_tmux_context(workspace: &Path) -> Result<(), LeaderError> {
|
|
350
|
+
let Some(current_endpoint) = crate::tmux_backend::socket_name_from_tmux_env() else {
|
|
351
|
+
return Ok(());
|
|
352
|
+
};
|
|
353
|
+
let expected = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
354
|
+
if tmux_endpoint_matches_socket_name(¤t_endpoint, &expected) {
|
|
355
|
+
return Ok(());
|
|
356
|
+
}
|
|
357
|
+
Err(LeaderError::Start(format!(
|
|
358
|
+
"Error: managed leader launch refused inside a different tmux server\nReason: current TMUX socket is {current_endpoint}, but this team uses {expected}\nAction: detach and run from a normal shell, or use --external-leader for the external leader topology"
|
|
359
|
+
)))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fn tmux_endpoint_matches_socket_name(endpoint: &str, socket_name: &str) -> bool {
|
|
363
|
+
endpoint == socket_name
|
|
364
|
+
|| Path::new(endpoint)
|
|
365
|
+
.file_name()
|
|
366
|
+
.and_then(|name| name.to_str())
|
|
367
|
+
== Some(socket_name)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn execute_managed_leader_plan(
|
|
371
|
+
plan: &LeaderStartPlan,
|
|
372
|
+
workspace: &Path,
|
|
373
|
+
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
374
|
+
let Some(session) = plan.session_name.as_ref() else {
|
|
375
|
+
return Err(LeaderError::Start("managed leader session missing".to_string()));
|
|
376
|
+
};
|
|
377
|
+
let Some(window) = plan.leader_window.as_ref() else {
|
|
378
|
+
return Err(LeaderError::Start("managed leader window missing".to_string()));
|
|
379
|
+
};
|
|
380
|
+
let transport = TmuxBackend::for_workspace(workspace);
|
|
381
|
+
let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
|
|
382
|
+
persist_managed_leader_binding(plan, workspace, &spawned)?;
|
|
383
|
+
spawn_managed_provider_startup_prompt_handler(
|
|
384
|
+
plan.provider,
|
|
385
|
+
workspace.to_path_buf(),
|
|
386
|
+
spawned.pane_id.as_str().to_string(),
|
|
387
|
+
);
|
|
388
|
+
let status = run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace)?;
|
|
389
|
+
let code = status.code();
|
|
390
|
+
if !status.success() {
|
|
391
|
+
return Err(LeaderError::Start(format!(
|
|
392
|
+
"leader launcher exited with status {}",
|
|
393
|
+
code.map(|c| c.to_string())
|
|
394
|
+
.unwrap_or_else(|| "signal".to_string())
|
|
395
|
+
)));
|
|
396
|
+
}
|
|
397
|
+
Ok(LeaderLaunchOutcome {
|
|
398
|
+
status: LeaderLaunchStatus::Exited,
|
|
399
|
+
exit_code: code,
|
|
400
|
+
session_name: plan.session_name.clone(),
|
|
401
|
+
reason: None,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
fn ensure_managed_leader_pane(
|
|
406
|
+
transport: &TmuxBackend,
|
|
407
|
+
session: &SessionName,
|
|
408
|
+
window: &WindowName,
|
|
409
|
+
plan: &LeaderStartPlan,
|
|
410
|
+
workspace: &Path,
|
|
411
|
+
) -> Result<SpawnResult, LeaderError> {
|
|
412
|
+
if transport.has_session(session).unwrap_or(false) {
|
|
413
|
+
if transport
|
|
414
|
+
.list_windows(session)
|
|
415
|
+
.unwrap_or_default()
|
|
416
|
+
.iter()
|
|
417
|
+
.any(|existing| existing.as_str() == window.as_str())
|
|
418
|
+
{
|
|
419
|
+
if let Some(existing) = transport
|
|
420
|
+
.list_targets()
|
|
421
|
+
.unwrap_or_default()
|
|
422
|
+
.into_iter()
|
|
423
|
+
.find(|pane| {
|
|
424
|
+
pane.session.as_str() == session.as_str()
|
|
425
|
+
&& pane.window_name.as_ref().map(WindowName::as_str)
|
|
426
|
+
== Some(window.as_str())
|
|
427
|
+
})
|
|
428
|
+
{
|
|
429
|
+
return Ok(SpawnResult {
|
|
430
|
+
pane_id: existing.pane_id,
|
|
431
|
+
session: session.clone(),
|
|
432
|
+
window: window.clone(),
|
|
433
|
+
child_pid: existing.pane_pid,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
transport
|
|
438
|
+
.spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
|
|
439
|
+
.map_err(|error| LeaderError::Start(error.to_string()))
|
|
440
|
+
} else {
|
|
441
|
+
transport
|
|
442
|
+
.spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
|
|
443
|
+
.map_err(|error| LeaderError::Start(error.to_string()))
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fn persist_managed_leader_binding(
|
|
448
|
+
plan: &LeaderStartPlan,
|
|
449
|
+
workspace: &Path,
|
|
450
|
+
spawned: &SpawnResult,
|
|
451
|
+
) -> Result<(), LeaderError> {
|
|
452
|
+
let identity = plan
|
|
453
|
+
.identity
|
|
454
|
+
.as_ref()
|
|
455
|
+
.ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
|
|
456
|
+
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
457
|
+
.unwrap_or_else(|_| serde_json::json!({}));
|
|
458
|
+
let owner_epoch = state
|
|
459
|
+
.get("owner_epoch")
|
|
460
|
+
.and_then(serde_json::Value::as_u64)
|
|
461
|
+
.or_else(|| {
|
|
462
|
+
state
|
|
463
|
+
.get("team_owner")
|
|
464
|
+
.and_then(|owner| owner.get("owner_epoch"))
|
|
465
|
+
.and_then(serde_json::Value::as_u64)
|
|
466
|
+
})
|
|
467
|
+
.unwrap_or(0)
|
|
468
|
+
.saturating_add(1);
|
|
469
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
470
|
+
let socket = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
471
|
+
let provider = serde_json::to_value(plan.provider)?;
|
|
472
|
+
let session = spawned.session.as_str().to_string();
|
|
473
|
+
let window = spawned.window.as_str().to_string();
|
|
474
|
+
let pane = spawned.pane_id.as_str().to_string();
|
|
475
|
+
let receiver = serde_json::json!({
|
|
476
|
+
"mode": "direct_tmux",
|
|
477
|
+
"status": "attached",
|
|
478
|
+
"provider": provider.clone(),
|
|
479
|
+
"pane_id": pane,
|
|
480
|
+
"pane": pane,
|
|
481
|
+
"session_name": session,
|
|
482
|
+
"window_name": window,
|
|
483
|
+
"tmux_socket": socket,
|
|
484
|
+
"leader_session_uuid": identity.leader_session_uuid,
|
|
485
|
+
"owner_epoch": owner_epoch,
|
|
486
|
+
"attached_at": now,
|
|
487
|
+
"discovery": "managed_launcher",
|
|
488
|
+
});
|
|
489
|
+
let owner = serde_json::json!({
|
|
490
|
+
"pane_id": pane,
|
|
491
|
+
"provider": provider.clone(),
|
|
492
|
+
"machine_fingerprint": identity.machine_fingerprint,
|
|
493
|
+
"leader_session_uuid": identity.leader_session_uuid,
|
|
494
|
+
"owner_epoch": owner_epoch,
|
|
495
|
+
"claimed_at": now,
|
|
496
|
+
"claimed_via": "claim-leader",
|
|
497
|
+
"os_user": identity.os_user,
|
|
498
|
+
});
|
|
499
|
+
if let Some(obj) = state.as_object_mut() {
|
|
500
|
+
obj.insert("session_name".to_string(), serde_json::json!(session));
|
|
501
|
+
obj.insert(
|
|
502
|
+
"active_team_key".to_string(),
|
|
503
|
+
serde_json::json!(identity.team_id.as_str()),
|
|
504
|
+
);
|
|
505
|
+
obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
506
|
+
obj.insert("is_external_leader".to_string(), serde_json::json!(false));
|
|
507
|
+
obj.insert(
|
|
508
|
+
"leader_client".to_string(),
|
|
509
|
+
serde_json::json!({
|
|
510
|
+
"diagnostic_only": true,
|
|
511
|
+
"attach_mode": if std::env::var_os("TMUX").is_some() { "switch-client" } else { "attach-session" },
|
|
512
|
+
"tmux": std::env::var("TMUX").ok(),
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
obj.insert("leader_receiver".to_string(), receiver);
|
|
516
|
+
obj.insert("team_owner".to_string(), owner);
|
|
517
|
+
obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
|
|
518
|
+
}
|
|
519
|
+
let entry = crate::state::projection::compact_team_state(&state);
|
|
520
|
+
if let Some(obj) = state.as_object_mut() {
|
|
521
|
+
let teams = obj
|
|
522
|
+
.entry("teams".to_string())
|
|
523
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
524
|
+
if let Some(teams) = teams.as_object_mut() {
|
|
525
|
+
teams.insert(identity.team_id.as_str().to_string(), entry);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
crate::state::persist::save_runtime_state(workspace, &state)?;
|
|
529
|
+
Ok(())
|
|
530
|
+
}
|
|
531
|
+
|
|
286
532
|
fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
|
|
287
533
|
if argv.iter().any(|arg| arg == "-d") {
|
|
288
534
|
return false;
|
|
@@ -342,6 +588,24 @@ fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: Pat
|
|
|
342
588
|
});
|
|
343
589
|
}
|
|
344
590
|
|
|
591
|
+
fn spawn_managed_provider_startup_prompt_handler(
|
|
592
|
+
provider: Provider,
|
|
593
|
+
workspace: PathBuf,
|
|
594
|
+
pane_id: String,
|
|
595
|
+
) {
|
|
596
|
+
std::thread::spawn(move || {
|
|
597
|
+
let transport = TmuxBackend::for_workspace(&workspace);
|
|
598
|
+
let _ = handle_exec_provider_startup_prompts(
|
|
599
|
+
provider,
|
|
600
|
+
&workspace,
|
|
601
|
+
&pane_id,
|
|
602
|
+
&transport,
|
|
603
|
+
30,
|
|
604
|
+
0.5,
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
345
609
|
fn tmux_transport_for_current_pane() -> TmuxBackend {
|
|
346
610
|
crate::tmux_backend::socket_name_from_tmux_env()
|
|
347
611
|
.map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
|
|
@@ -94,6 +94,7 @@ use super::*;
|
|
|
94
94
|
#[test]
|
|
95
95
|
fn leader_start_mode_serializes_to_python_strings() {
|
|
96
96
|
assert_eq!(serde_json::to_string(&LeaderStartMode::ExecProvider).unwrap(), "\"exec_provider\"");
|
|
97
|
+
assert_eq!(serde_json::to_string(&LeaderStartMode::ManagedTmuxClient).unwrap(), "\"managed_tmux_client\"");
|
|
97
98
|
assert_eq!(serde_json::to_string(&LeaderStartMode::NewTmuxSession).unwrap(), "\"new_tmux_session\"");
|
|
98
99
|
assert_eq!(serde_json::to_string(&LeaderStartMode::AttachExisting).unwrap(), "\"attach_existing\"");
|
|
99
100
|
}
|