@team-agent/installer 0.3.0 → 0.3.2
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 +38 -7
- package/crates/team-agent/src/cli/emit.rs +182 -54
- package/crates/team-agent/src/cli/mod.rs +703 -35
- package/crates/team-agent/src/cli/status_port.rs +170 -44
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/health.rs +130 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +554 -65
- package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
- package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
- package/crates/team-agent/src/lifecycle/restart.rs +20 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +574 -12
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/results.rs +218 -49
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/provider/adapter.rs +95 -10
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/state/persist.rs +113 -1
- package/crates/team-agent/src/state/projection.rs +127 -3
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +124 -12
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
//! status_port extracted from cli::mod inline placeholder.
|
|
2
2
|
use super::*;
|
|
3
|
+
use crate::state::projection::OwnerTeamResolution;
|
|
3
4
|
use crate::transport::Transport;
|
|
5
|
+
use rusqlite::params;
|
|
4
6
|
|
|
5
7
|
/// `status.status(workspace, as_json, compact)`(`queries.py:33`,**有副作用**:capture→refresh→save)。
|
|
6
8
|
pub fn status(workspace: &Path, compact: bool, detail: bool) -> Result<Value, CliError> {
|
|
7
|
-
let _ = detail;
|
|
8
9
|
let state = read_runtime_state(workspace);
|
|
10
|
+
status_scoped(workspace, &state, None, compact, detail)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
pub fn status_scoped(
|
|
14
|
+
workspace: &Path,
|
|
15
|
+
state: &Value,
|
|
16
|
+
owner_team_id: Option<&str>,
|
|
17
|
+
compact: bool,
|
|
18
|
+
detail: bool,
|
|
19
|
+
) -> Result<Value, CliError> {
|
|
20
|
+
let _ = detail;
|
|
21
|
+
let resolved_owner_team_id = resolve_status_owner_team(workspace, owner_team_id)?;
|
|
22
|
+
let owner_team_id = resolved_owner_team_id.as_deref().or(owner_team_id);
|
|
9
23
|
let health = crate::coordinator::coordinator_health(
|
|
10
24
|
&crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
|
|
11
25
|
);
|
|
@@ -30,11 +44,11 @@ use crate::transport::Transport;
|
|
|
30
44
|
"leader_receiver": leader_receiver,
|
|
31
45
|
"teams": state.get("teams").cloned().unwrap_or_else(|| json!({})),
|
|
32
46
|
"agents": agents,
|
|
33
|
-
"agent_health": agent_health(&conn)?,
|
|
47
|
+
"agent_health": agent_health(&conn, owner_team_id)?,
|
|
34
48
|
"tasks": tasks,
|
|
35
|
-
"messages": message_counts(&conn)?,
|
|
36
|
-
"queued_messages": queued_messages(&conn, 8)?,
|
|
37
|
-
"results": result_counts(&conn)?,
|
|
49
|
+
"messages": message_counts(&conn, owner_team_id)?,
|
|
50
|
+
"queued_messages": queued_messages(&conn, owner_team_id, 8)?,
|
|
51
|
+
"results": result_counts(&conn, owner_team_id)?,
|
|
38
52
|
"latest_results": json!([]),
|
|
39
53
|
"coordinator": coordinator_health_value(health),
|
|
40
54
|
"last_events": Value::Array(
|
|
@@ -51,7 +65,17 @@ use crate::transport::Transport;
|
|
|
51
65
|
}
|
|
52
66
|
/// `status.format_status(workspace, agent)`(人读)。
|
|
53
67
|
pub fn format_status(workspace: &Path, agent: Option<&str>) -> Result<String, CliError> {
|
|
54
|
-
let
|
|
68
|
+
let state = read_runtime_state(workspace);
|
|
69
|
+
format_status_scoped(workspace, &state, None, agent)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pub fn format_status_scoped(
|
|
73
|
+
workspace: &Path,
|
|
74
|
+
state: &Value,
|
|
75
|
+
owner_team_id: Option<&str>,
|
|
76
|
+
agent: Option<&str>,
|
|
77
|
+
) -> Result<String, CliError> {
|
|
78
|
+
let status = status_scoped(workspace, state, owner_team_id, true, false)?;
|
|
55
79
|
Ok(match agent {
|
|
56
80
|
Some(agent) => format!("agent {agent}: {}", status.pointer("/agents").is_some()),
|
|
57
81
|
None => crate::cli::format_status_summary(&status),
|
|
@@ -165,6 +189,32 @@ use crate::transport::Transport;
|
|
|
165
189
|
.unwrap_or_else(|| json!({}))
|
|
166
190
|
}
|
|
167
191
|
|
|
192
|
+
fn resolve_status_owner_team(
|
|
193
|
+
workspace: &Path,
|
|
194
|
+
owner_team_id: Option<&str>,
|
|
195
|
+
) -> Result<Option<String>, CliError> {
|
|
196
|
+
let Some(requested) = owner_team_id.filter(|team| !team.is_empty()) else {
|
|
197
|
+
return Ok(None);
|
|
198
|
+
};
|
|
199
|
+
let state = read_runtime_state(workspace);
|
|
200
|
+
match crate::state::projection::resolve_owner_team_id(&state, requested) {
|
|
201
|
+
OwnerTeamResolution::Canonical(canonical) => Ok(Some(canonical)),
|
|
202
|
+
OwnerTeamResolution::LegacyAlias { requested, canonical } => {
|
|
203
|
+
let log = crate::event_log::EventLog::new(workspace);
|
|
204
|
+
crate::messaging::delivery::normalize_owner_team_id_rows(
|
|
205
|
+
workspace,
|
|
206
|
+
&requested,
|
|
207
|
+
&canonical,
|
|
208
|
+
None,
|
|
209
|
+
Some(&log),
|
|
210
|
+
)
|
|
211
|
+
.map_err(CliError::from)?;
|
|
212
|
+
Ok(Some(canonical))
|
|
213
|
+
}
|
|
214
|
+
OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => Ok(None),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
168
218
|
fn agent_window(agent_id: &str, agent_state: &Value) -> String {
|
|
169
219
|
["window", "window_name"]
|
|
170
220
|
.iter()
|
|
@@ -224,15 +274,15 @@ use crate::transport::Transport;
|
|
|
224
274
|
.unwrap_or(false)
|
|
225
275
|
}
|
|
226
276
|
|
|
227
|
-
fn message_counts(conn: &rusqlite::Connection) -> Result<Value, CliError> {
|
|
228
|
-
status_counts(conn, "messages")
|
|
277
|
+
fn message_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
|
|
278
|
+
status_counts(conn, "messages", owner_team_id)
|
|
229
279
|
}
|
|
230
280
|
|
|
231
|
-
fn result_counts(conn: &rusqlite::Connection) -> Result<Value, CliError> {
|
|
232
|
-
let by_status = result_status_counts(conn)?;
|
|
233
|
-
let total = count_rows(conn, "results")?;
|
|
234
|
-
let invalid = count_where_status(conn, "results", "invalid")?;
|
|
235
|
-
let collected = count_where_status(conn, "results", "collected")?;
|
|
281
|
+
fn result_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
|
|
282
|
+
let by_status = result_status_counts(conn, owner_team_id)?;
|
|
283
|
+
let total = count_rows(conn, "results", owner_team_id)?;
|
|
284
|
+
let invalid = count_where_status(conn, "results", owner_team_id, "invalid")?;
|
|
285
|
+
let collected = count_where_status(conn, "results", owner_team_id, "collected")?;
|
|
236
286
|
let uncollected = total.saturating_sub(collected).saturating_sub(invalid);
|
|
237
287
|
Ok(json!({
|
|
238
288
|
"total": total,
|
|
@@ -243,10 +293,24 @@ use crate::transport::Transport;
|
|
|
243
293
|
}))
|
|
244
294
|
}
|
|
245
295
|
|
|
246
|
-
fn status_counts(
|
|
247
|
-
|
|
296
|
+
fn status_counts(
|
|
297
|
+
conn: &rusqlite::Connection,
|
|
298
|
+
table: &str,
|
|
299
|
+
owner_team_id: Option<&str>,
|
|
300
|
+
) -> Result<Value, CliError> {
|
|
301
|
+
let sql = match owner_team_id {
|
|
302
|
+
Some(_) => format!(
|
|
303
|
+
"select status, count(*) from {table}
|
|
304
|
+
where owner_team_id = ?1
|
|
305
|
+
group by status order by status"
|
|
306
|
+
),
|
|
307
|
+
None => format!("select status, count(*) from {table} group by status order by status"),
|
|
308
|
+
};
|
|
248
309
|
let mut stmt = conn.prepare(&sql).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
249
|
-
let mut rows =
|
|
310
|
+
let mut rows = match owner_team_id {
|
|
311
|
+
Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
312
|
+
None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
313
|
+
};
|
|
250
314
|
let mut out = Map::new();
|
|
251
315
|
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
252
316
|
let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -256,16 +320,28 @@ use crate::transport::Transport;
|
|
|
256
320
|
Ok(Value::Object(out))
|
|
257
321
|
}
|
|
258
322
|
|
|
259
|
-
fn result_status_counts(conn: &rusqlite::Connection) -> Result<Value, CliError> {
|
|
260
|
-
let
|
|
261
|
-
|
|
323
|
+
fn result_status_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
|
|
324
|
+
let sql = match owner_team_id {
|
|
325
|
+
Some(_) => {
|
|
326
|
+
"select status, count(*) from results
|
|
327
|
+
where status not in ('collected', 'invalid') and owner_team_id = ?1
|
|
328
|
+
group by status
|
|
329
|
+
order by status"
|
|
330
|
+
}
|
|
331
|
+
None => {
|
|
262
332
|
"select status, count(*) from results
|
|
263
333
|
where status not in ('collected', 'invalid')
|
|
264
334
|
group by status
|
|
265
|
-
order by status"
|
|
266
|
-
|
|
335
|
+
order by status"
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
let mut stmt = conn
|
|
339
|
+
.prepare(sql)
|
|
267
340
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
268
|
-
let mut rows =
|
|
341
|
+
let mut rows = match owner_team_id {
|
|
342
|
+
Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
343
|
+
None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
344
|
+
};
|
|
269
345
|
let mut out = Map::new();
|
|
270
346
|
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
271
347
|
let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -275,19 +351,32 @@ use crate::transport::Transport;
|
|
|
275
351
|
Ok(Value::Object(out))
|
|
276
352
|
}
|
|
277
353
|
|
|
278
|
-
fn queued_messages(
|
|
354
|
+
fn queued_messages(
|
|
355
|
+
conn: &rusqlite::Connection,
|
|
356
|
+
owner_team_id: Option<&str>,
|
|
357
|
+
limit: usize,
|
|
358
|
+
) -> Result<Value, CliError> {
|
|
279
359
|
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
280
|
-
let
|
|
281
|
-
|
|
360
|
+
let sql = match owner_team_id {
|
|
361
|
+
Some(_) => {
|
|
362
|
+
"select message_id, recipient, status, created_at, delivery_attempts
|
|
363
|
+
from messages
|
|
364
|
+
where status like 'queued%' and owner_team_id = ?1
|
|
365
|
+
order by created_at desc
|
|
366
|
+
limit ?2"
|
|
367
|
+
}
|
|
368
|
+
None => {
|
|
282
369
|
"select message_id, recipient, status, created_at, delivery_attempts
|
|
283
370
|
from messages
|
|
284
371
|
where status like 'queued%'
|
|
285
372
|
order by created_at desc
|
|
286
|
-
limit ?1"
|
|
287
|
-
|
|
373
|
+
limit ?1"
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
let mut stmt = conn
|
|
377
|
+
.prepare(sql)
|
|
288
378
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
289
|
-
let
|
|
290
|
-
.query_map([limit], |row| {
|
|
379
|
+
let map_row = |row: &rusqlite::Row<'_>| {
|
|
291
380
|
Ok(json!({
|
|
292
381
|
"message_id": row.get::<_, String>(0)?,
|
|
293
382
|
"recipient": row.get::<_, Option<String>>(1)?,
|
|
@@ -295,8 +384,12 @@ use crate::transport::Transport;
|
|
|
295
384
|
"created_at": row.get::<_, Option<String>>(3)?,
|
|
296
385
|
"delivery_attempts": row.get::<_, i64>(4)?,
|
|
297
386
|
}))
|
|
298
|
-
}
|
|
299
|
-
|
|
387
|
+
};
|
|
388
|
+
let rows = match owner_team_id {
|
|
389
|
+
Some(team) => stmt.query_map(params![team, limit], map_row),
|
|
390
|
+
None => stmt.query_map(params![limit], map_row),
|
|
391
|
+
}
|
|
392
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
300
393
|
let values = rows
|
|
301
394
|
.collect::<Result<Vec<_>, _>>()
|
|
302
395
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -410,30 +503,63 @@ use crate::transport::Transport;
|
|
|
410
503
|
Value::Array(items.iter().skip(start).cloned().collect())
|
|
411
504
|
}
|
|
412
505
|
|
|
413
|
-
fn count_rows(
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
506
|
+
fn count_rows(
|
|
507
|
+
conn: &rusqlite::Connection,
|
|
508
|
+
table: &str,
|
|
509
|
+
owner_team_id: Option<&str>,
|
|
510
|
+
) -> Result<i64, CliError> {
|
|
511
|
+
match owner_team_id {
|
|
512
|
+
Some(team) => {
|
|
513
|
+
let sql = format!("select count(*) from {table} where owner_team_id = ?1");
|
|
514
|
+
conn.query_row(&sql, [team], |row| row.get::<_, i64>(0))
|
|
515
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
516
|
+
}
|
|
517
|
+
None => {
|
|
518
|
+
let sql = format!("select count(*) from {table}");
|
|
519
|
+
conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
|
|
520
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
521
|
+
}
|
|
522
|
+
}
|
|
417
523
|
}
|
|
418
524
|
|
|
419
525
|
fn count_where_status(
|
|
420
526
|
conn: &rusqlite::Connection,
|
|
421
527
|
table: &str,
|
|
528
|
+
owner_team_id: Option<&str>,
|
|
422
529
|
status: &str,
|
|
423
530
|
) -> Result<i64, CliError> {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
531
|
+
match owner_team_id {
|
|
532
|
+
Some(team) => {
|
|
533
|
+
let sql = format!("select count(*) from {table} where status = ?1 and owner_team_id = ?2");
|
|
534
|
+
conn.query_row(&sql, params![status, team], |row| row.get::<_, i64>(0))
|
|
535
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
536
|
+
}
|
|
537
|
+
None => {
|
|
538
|
+
let sql = format!("select count(*) from {table} where status = ?1");
|
|
539
|
+
conn.query_row(&sql, [status], |row| row.get::<_, i64>(0))
|
|
540
|
+
.map_err(|e| CliError::Runtime(e.to_string()))
|
|
541
|
+
}
|
|
542
|
+
}
|
|
427
543
|
}
|
|
428
544
|
|
|
429
|
-
fn agent_health(conn: &rusqlite::Connection) -> Result<Value, CliError> {
|
|
430
|
-
let
|
|
431
|
-
|
|
545
|
+
fn agent_health(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
|
|
546
|
+
let sql = match owner_team_id {
|
|
547
|
+
Some(_) => {
|
|
548
|
+
"select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
|
|
549
|
+
from agent_health where owner_team_id = ?1 order by agent_id"
|
|
550
|
+
}
|
|
551
|
+
None => {
|
|
432
552
|
"select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
|
|
433
|
-
from agent_health order by agent_id"
|
|
434
|
-
|
|
553
|
+
from agent_health order by agent_id"
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
let mut stmt = conn
|
|
557
|
+
.prepare(sql)
|
|
435
558
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
436
|
-
let mut rows =
|
|
559
|
+
let mut rows = match owner_team_id {
|
|
560
|
+
Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
561
|
+
None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
562
|
+
};
|
|
437
563
|
let mut out = Map::new();
|
|
438
564
|
while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
|
|
439
565
|
let agent_id: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -152,6 +152,7 @@ fn current_uid() -> Option<String> {
|
|
|
152
152
|
let ws = crate::model::paths::team_workspace(&team).unwrap();
|
|
153
153
|
let _guard = TeamSocketGuard { ws };
|
|
154
154
|
let args = QuickStartArgs {
|
|
155
|
+
workspace: crate::model::paths::team_workspace(&team).unwrap(),
|
|
155
156
|
agents_dir: team.clone(),
|
|
156
157
|
name: None,
|
|
157
158
|
team_id: None,
|
|
@@ -177,6 +178,7 @@ fn current_uid() -> Option<String> {
|
|
|
177
178
|
std::fs::write(team.join("TEAM.md"), DELEG_TEAM_MD).unwrap();
|
|
178
179
|
std::fs::write(team.join("agents").join("broken.md"), DELEG_INVALID_ROLE).unwrap();
|
|
179
180
|
let args = QuickStartArgs {
|
|
181
|
+
workspace: crate::model::paths::team_workspace(&team).unwrap(),
|
|
180
182
|
agents_dir: team,
|
|
181
183
|
name: None,
|
|
182
184
|
team_id: None,
|
|
@@ -253,6 +253,7 @@ pub struct InteractionCounts {
|
|
|
253
253
|
/// `quick-start`(`parser.py:105`)。
|
|
254
254
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
255
255
|
pub struct QuickStartArgs {
|
|
256
|
+
pub workspace: PathBuf,
|
|
256
257
|
pub agents_dir: PathBuf,
|
|
257
258
|
pub name: Option<String>,
|
|
258
259
|
pub team_id: Option<String>,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
use std::io::{Read, Seek, SeekFrom};
|
|
4
4
|
use std::path::{Path, PathBuf};
|
|
5
5
|
use std::process::{Command, Stdio};
|
|
6
|
+
use std::time::Duration;
|
|
6
7
|
|
|
7
8
|
use serde_json::Value;
|
|
8
9
|
use thiserror::Error;
|
|
@@ -119,6 +120,9 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
|
|
|
119
120
|
pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopError> {
|
|
120
121
|
let pid_path = coordinator_pid_path(workspace);
|
|
121
122
|
if !pid_path.exists() {
|
|
123
|
+
if let Some(report) = stop_discovered_coordinators(workspace)? {
|
|
124
|
+
return Ok(report);
|
|
125
|
+
}
|
|
122
126
|
return Ok(StopReport {
|
|
123
127
|
ok: true,
|
|
124
128
|
status: StopOutcome::Missing,
|
|
@@ -134,6 +138,15 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
134
138
|
pid: None,
|
|
135
139
|
});
|
|
136
140
|
};
|
|
141
|
+
if pid_is_running(pid).ok() == Some(false) {
|
|
142
|
+
remove_file_if_exists(&pid_path)?;
|
|
143
|
+
remove_file_if_exists(&coordinator_meta_path(workspace))?;
|
|
144
|
+
return Ok(StopReport {
|
|
145
|
+
ok: true,
|
|
146
|
+
status: StopOutcome::Missing,
|
|
147
|
+
pid: Some(pid),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
137
150
|
let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
|
|
138
151
|
return Ok(StopReport {
|
|
139
152
|
ok: false,
|
|
@@ -158,6 +171,123 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
158
171
|
})
|
|
159
172
|
}
|
|
160
173
|
|
|
174
|
+
fn stop_discovered_coordinators(
|
|
175
|
+
workspace: &WorkspacePath,
|
|
176
|
+
) -> Result<Option<StopReport>, StopError> {
|
|
177
|
+
let pids = discover_coordinator_pids(workspace);
|
|
178
|
+
if pids.is_empty() {
|
|
179
|
+
return Ok(None);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let mut stopped = None;
|
|
183
|
+
let mut failed = None;
|
|
184
|
+
for pid in pids {
|
|
185
|
+
if terminate_pid(pid) {
|
|
186
|
+
stopped.get_or_insert(pid);
|
|
187
|
+
} else {
|
|
188
|
+
failed.get_or_insert(pid);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
remove_file_if_exists(&coordinator_meta_path(workspace))?;
|
|
192
|
+
|
|
193
|
+
if let Some(pid) = stopped {
|
|
194
|
+
Ok(Some(StopReport {
|
|
195
|
+
ok: true,
|
|
196
|
+
status: StopOutcome::Stopped,
|
|
197
|
+
pid: Some(pid),
|
|
198
|
+
}))
|
|
199
|
+
} else {
|
|
200
|
+
Ok(Some(StopReport {
|
|
201
|
+
ok: false,
|
|
202
|
+
status: StopOutcome::KillFailed,
|
|
203
|
+
pid: failed,
|
|
204
|
+
}))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
|
|
209
|
+
let output = match Command::new("ps")
|
|
210
|
+
.args(["-axo", "pid=,command="])
|
|
211
|
+
.output()
|
|
212
|
+
{
|
|
213
|
+
Ok(output) if output.status.success() => output,
|
|
214
|
+
_ => return Vec::new(),
|
|
215
|
+
};
|
|
216
|
+
let text = String::from_utf8_lossy(&output.stdout);
|
|
217
|
+
let candidates = workspace_match_candidates(workspace.as_path());
|
|
218
|
+
text.lines()
|
|
219
|
+
.filter_map(|line| parse_ps_command_line(line))
|
|
220
|
+
.filter(|(pid, command)| {
|
|
221
|
+
*pid != std::process::id()
|
|
222
|
+
&& coordinator_command_matches_workspace(command, &candidates)
|
|
223
|
+
})
|
|
224
|
+
.map(|(pid, _)| Pid::new(pid))
|
|
225
|
+
.collect()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
|
|
229
|
+
let line = line.trim_start();
|
|
230
|
+
let split = line
|
|
231
|
+
.find(char::is_whitespace)
|
|
232
|
+
.unwrap_or(line.len());
|
|
233
|
+
let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
|
|
234
|
+
let command = line.get(split..)?.trim();
|
|
235
|
+
Some((pid, command))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn workspace_match_candidates(workspace: &Path) -> Vec<String> {
|
|
239
|
+
let mut candidates = vec![workspace.to_string_lossy().to_string()];
|
|
240
|
+
if let Ok(canonical) = workspace.canonicalize() {
|
|
241
|
+
let text = canonical.to_string_lossy().to_string();
|
|
242
|
+
if !candidates.iter().any(|candidate| candidate == &text) {
|
|
243
|
+
candidates.push(text);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
candidates
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -> bool {
|
|
250
|
+
command
|
|
251
|
+
.split_whitespace()
|
|
252
|
+
.any(|token| token == "team-agent" || token.ends_with("/team-agent"))
|
|
253
|
+
&& command.split_whitespace().any(|token| token == "coordinator")
|
|
254
|
+
&& command.contains("--workspace")
|
|
255
|
+
&& workspaces.iter().any(|workspace| command.contains(workspace))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fn terminate_pid(pid: Pid) -> bool {
|
|
259
|
+
if pid_is_running(pid).ok() == Some(false) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if !send_signal(pid, libc::SIGTERM) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
if wait_until_not_running(pid, Duration::from_millis(750)) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
send_signal(pid, libc::SIGKILL) && wait_until_not_running(pid, Duration::from_millis(750))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
fn send_signal(pid: Pid, signal: libc::c_int) -> bool {
|
|
272
|
+
let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
|
|
273
|
+
return false;
|
|
274
|
+
};
|
|
275
|
+
unsafe { libc::kill(pid_t, signal) == 0 }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fn wait_until_not_running(pid: Pid, timeout: Duration) -> bool {
|
|
279
|
+
let start = std::time::Instant::now();
|
|
280
|
+
loop {
|
|
281
|
+
if pid_is_running(pid).ok() != Some(true) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if start.elapsed() >= timeout {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
161
291
|
// ===========================================================================
|
|
162
292
|
// metadata 身份原语(metadata.py)—— 自由函数面
|
|
163
293
|
// ===========================================================================
|
|
@@ -424,7 +424,8 @@ fn claim_lease_no_incident_with_target(
|
|
|
424
424
|
));
|
|
425
425
|
}
|
|
426
426
|
let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
|
|
427
|
-
|
|
427
|
+
let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
|
|
428
|
+
if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
|
|
428
429
|
return Ok(LeaseResult {
|
|
429
430
|
ok: true,
|
|
430
431
|
status: LeaseStatus::AlreadyBound,
|
|
@@ -438,7 +439,12 @@ fn claim_lease_no_incident_with_target(
|
|
|
438
439
|
}
|
|
439
440
|
let owner_live = bound_pane_id
|
|
440
441
|
.as_deref()
|
|
441
|
-
.is_some_and(|pane|
|
|
442
|
+
.is_some_and(|pane| {
|
|
443
|
+
if pane == caller_pane.as_str() && !bound_endpoint_matches_caller {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
liveness.liveness(pane) == PaneLiveness::Live
|
|
447
|
+
});
|
|
442
448
|
if owner_live && !confirm {
|
|
443
449
|
emit_lease_refusal(
|
|
444
450
|
event_log,
|
|
@@ -596,6 +602,20 @@ fn bound_pane(state: &Value) -> Option<String> {
|
|
|
596
602
|
.or_else(|| get_path_str(state, &["team_owner", "pane_id"]).filter(|v| !v.is_empty()))
|
|
597
603
|
}
|
|
598
604
|
|
|
605
|
+
fn bound_endpoint_matches_current_process(state: &Value) -> bool {
|
|
606
|
+
let Some(bound) = get_path_str(state, &["leader_receiver", "tmux_socket"]).filter(|v| !v.is_empty()) else {
|
|
607
|
+
return true;
|
|
608
|
+
};
|
|
609
|
+
let Some(current) = crate::tmux_backend::socket_name_from_tmux_env() else {
|
|
610
|
+
return false;
|
|
611
|
+
};
|
|
612
|
+
tmux_endpoints_match(&bound, ¤t)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
fn tmux_endpoints_match(bound: &str, current: &str) -> bool {
|
|
616
|
+
bound == current
|
|
617
|
+
}
|
|
618
|
+
|
|
599
619
|
fn prior_provider(state: &Value) -> Provider {
|
|
600
620
|
get_path_str(state, &["leader_receiver", "provider"])
|
|
601
621
|
.or_else(|| get_path_str(state, &["team_owner", "provider"]))
|
|
@@ -844,6 +864,7 @@ fn make_receiver(
|
|
|
844
864
|
pane_index: target.as_ref().and_then(|t| t.pane_index.map(|v| v.to_string())),
|
|
845
865
|
pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
|
|
846
866
|
pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
|
|
867
|
+
tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
|
|
847
868
|
fingerprint: target.as_ref().map(receiver_fingerprint),
|
|
848
869
|
leader_session_uuid: Some(uuid.clone()),
|
|
849
870
|
owner_epoch: Some(epoch),
|
|
@@ -31,6 +31,7 @@ fn receiver(pane: &str, uuid: &str, epoch: u64) -> LeaderReceiver {
|
|
|
31
31
|
pane_index: None,
|
|
32
32
|
pane_tty: None,
|
|
33
33
|
pane_current_command: None,
|
|
34
|
+
tmux_socket: None,
|
|
34
35
|
fingerprint: None,
|
|
35
36
|
leader_session_uuid: serde_json::from_value(Value::String(uuid.to_string())).ok(),
|
|
36
37
|
owner_epoch: Some(OwnerEpoch(epoch)),
|
|
@@ -1013,6 +1013,7 @@ fn receiver_from_candidate(
|
|
|
1013
1013
|
pane_index: target.pane_index.clone(),
|
|
1014
1014
|
pane_tty: target.tty.clone(),
|
|
1015
1015
|
pane_current_command: target.current_command.clone(),
|
|
1016
|
+
tmux_socket: prior.tmux_socket.clone(),
|
|
1016
1017
|
fingerprint: target.fingerprint.clone(),
|
|
1017
1018
|
leader_session_uuid: uuid,
|
|
1018
1019
|
owner_epoch: Some(epoch),
|
|
@@ -1035,6 +1036,7 @@ fn empty_prior(provider: Provider, epoch: OwnerEpoch) -> LeaderReceiver {
|
|
|
1035
1036
|
pane_index: None,
|
|
1036
1037
|
pane_tty: None,
|
|
1037
1038
|
pane_current_command: None,
|
|
1039
|
+
tmux_socket: None,
|
|
1038
1040
|
fingerprint: None,
|
|
1039
1041
|
leader_session_uuid: None,
|
|
1040
1042
|
owner_epoch: Some(epoch),
|
|
@@ -18,7 +18,7 @@ use super::*;
|
|
|
18
18
|
"golden new_owner includes os_user");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
// D2 [BLOCK] — claim-path leader_receiver is golden's
|
|
21
|
+
// D2 [BLOCK] — claim-path leader_receiver is golden's 15 keys in golden order; NO
|
|
22
22
|
// fingerprint/requested_provider/warning. Golden _receiver_from_claim_target (__init__.py:861-877).
|
|
23
23
|
// Rust LeaderReceiver serializes all 17 (no skip_serializing_if) -> 3 always-null extras leak. RED.
|
|
24
24
|
// (The POPULATED tmux values session_name/window_*/pane_* come from the caller-target scan — a
|
|
@@ -26,9 +26,12 @@ use super::*;
|
|
|
26
26
|
// locked here are unchanged by that scan.)
|
|
27
27
|
#[test]
|
|
28
28
|
#[serial_test::serial(env)]
|
|
29
|
-
fn
|
|
29
|
+
fn d2_claim_leader_receiver_is_fifteen_golden_keys_in_order_no_extras() {
|
|
30
30
|
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
31
|
-
let _e = EnvGuard::apply(&[
|
|
31
|
+
let _e = EnvGuard::apply(&[
|
|
32
|
+
("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
|
|
33
|
+
("TMUX", Some("/tmp/tmux-501/default,123,0")),
|
|
34
|
+
]);
|
|
32
35
|
let ws = p2_temp_ws("d2_recv_keys");
|
|
33
36
|
let mut state = serde_json::json!({"session_name": "team-agent-x"});
|
|
34
37
|
let r = claim_lease_no_incident(&ws, &mut state, None, &TeamKey::new("current"),
|
|
@@ -42,9 +45,9 @@ use super::*;
|
|
|
42
45
|
let keys: Vec<&str> = recv.keys().map(String::as_str).collect();
|
|
43
46
|
assert_eq!(keys, vec![
|
|
44
47
|
"mode","status","provider","pane_id","session_name","window_index","window_name",
|
|
45
|
-
"pane_index","pane_tty","pane_current_command","
|
|
46
|
-
"attached_at","discovery",
|
|
47
|
-
], "golden _receiver_from_claim_target
|
|
48
|
+
"pane_index","pane_tty","pane_current_command","tmux_socket","leader_session_uuid",
|
|
49
|
+
"owner_epoch","attached_at","discovery",
|
|
50
|
+
], "golden _receiver_from_claim_target 15-key set + ORDER (__init__.py:861-877 + BUG-4 socket-qualified receiver)");
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// D2 seam — the caller-target SCAN that fills session_name/window_index/window_name/pane_index/
|
|
@@ -197,6 +197,7 @@ use super::*;
|
|
|
197
197
|
pane_index: Some("2".into()),
|
|
198
198
|
pane_tty: Some("/dev/ttys001".into()),
|
|
199
199
|
pane_current_command: Some("claude".into()),
|
|
200
|
+
tmux_socket: None,
|
|
200
201
|
fingerprint: Some("fp".into()),
|
|
201
202
|
leader_session_uuid: Some(uuid("fp", "/ws", "u", "default")),
|
|
202
203
|
owner_epoch: Some(OwnerEpoch(3)),
|