@team-agent/installer 0.5.91 → 0.5.94
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 +43 -10
- package/crates/team-agent/src/cli/diagnose.rs +78 -39
- package/crates/team-agent/src/cli/mod.rs +407 -450
- package/crates/team-agent/src/cli/models.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +154 -30
- package/crates/team-agent/src/cli/triage.rs +183 -0
- package/crates/team-agent/src/coordinator/health.rs +133 -31
- package/crates/team-agent/src/coordinator/tick.rs +62 -8
- package/crates/team-agent/src/coordinator/types.rs +2 -0
- package/crates/team-agent/src/db/migration.rs +36 -15
- package/crates/team-agent/src/kill_audit.rs +41 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +48 -14
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +4 -8
- package/crates/team-agent/src/lifecycle/launch.rs +17 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +10 -7
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
- package/crates/team-agent/src/platform/process.rs +4 -45
- package/crates/team-agent/src/redaction.rs +5 -1
- package/crates/team-agent/src/tmux_backend.rs +113 -3
- package/crates/team-agent/src/topology.rs +17 -11
- package/crates/team-agent/src/transport/test_support.rs +30 -0
- package/crates/team-agent/src/transport.rs +41 -0
- package/package.json +4 -4
|
@@ -74,7 +74,19 @@ use super::types::{
|
|
|
74
74
|
/// returns: HealthReport。ok = 进程在跑 ∧ metadata 三元全等 ∧ 二进制身份一致 ∧ schema 兼容;service_available 刻意排除二进制身份,表示「这个 daemon 还能处理本队队列」;status 区分 Missing / InvalidPid / Running / Stale
|
|
75
75
|
/// ---
|
|
76
76
|
pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
|
|
77
|
-
|
|
77
|
+
coordinator_health_with_schema(workspace, message_store_schema_health(workspace))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Read-only coordinator health for diagnose. Unlike the lifecycle path, this
|
|
81
|
+
/// never opens MessageStore and therefore cannot initialize or rewrite team.db.
|
|
82
|
+
pub fn coordinator_health_read_only(workspace: &WorkspacePath) -> HealthReport {
|
|
83
|
+
coordinator_health_with_schema(workspace, message_store_schema_health_read_only(workspace))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn coordinator_health_with_schema(
|
|
87
|
+
workspace: &WorkspacePath,
|
|
88
|
+
schema: SchemaHealth,
|
|
89
|
+
) -> HealthReport {
|
|
78
90
|
let current_binary_identity = current_coordinator_binary_identity();
|
|
79
91
|
let pid_path = coordinator_pid_path(workspace);
|
|
80
92
|
let pid = read_pid_file(&pid_path);
|
|
@@ -320,6 +332,7 @@ pub fn start_coordinator_with_team(
|
|
|
320
332
|
}
|
|
321
333
|
}
|
|
322
334
|
command
|
|
335
|
+
.current_dir(workspace.as_path())
|
|
323
336
|
.stdin(Stdio::null())
|
|
324
337
|
.stdout(Stdio::from(log))
|
|
325
338
|
.stderr(Stdio::from(log_err));
|
|
@@ -420,6 +433,16 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
420
433
|
pid: Some(pid),
|
|
421
434
|
});
|
|
422
435
|
}
|
|
436
|
+
let metadata_owned = read_coordinator_metadata(workspace)
|
|
437
|
+
.as_ref()
|
|
438
|
+
.is_some_and(|metadata| metadata.pid == pid);
|
|
439
|
+
if !metadata_owned || !coordinator_pid_owned_by_workspace(workspace, pid) {
|
|
440
|
+
return Ok(StopReport {
|
|
441
|
+
ok: false,
|
|
442
|
+
status: StopOutcome::NotOwned,
|
|
443
|
+
pid: Some(pid),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
423
446
|
if pid.get() == std::process::id() {
|
|
424
447
|
remove_file_if_exists(&pid_path)?;
|
|
425
448
|
remove_file_if_exists(&coordinator_meta_path(workspace))?;
|
|
@@ -429,7 +452,7 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
429
452
|
pid: Some(pid),
|
|
430
453
|
});
|
|
431
454
|
}
|
|
432
|
-
if !
|
|
455
|
+
if !terminate_pid_for_workspace(pid, workspace.as_path()) {
|
|
433
456
|
return Ok(StopReport {
|
|
434
457
|
ok: false,
|
|
435
458
|
status: StopOutcome::KillFailed,
|
|
@@ -456,7 +479,7 @@ fn stop_discovered_coordinators(
|
|
|
456
479
|
let mut stopped = None;
|
|
457
480
|
let mut failed = None;
|
|
458
481
|
for pid in pids {
|
|
459
|
-
if
|
|
482
|
+
if terminate_pid_for_workspace(pid, workspace.as_path()) {
|
|
460
483
|
stopped.get_or_insert(pid);
|
|
461
484
|
} else {
|
|
462
485
|
failed.get_or_insert(pid);
|
|
@@ -489,47 +512,98 @@ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
|
|
|
489
512
|
_ => return Vec::new(),
|
|
490
513
|
};
|
|
491
514
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
492
|
-
let
|
|
515
|
+
let metadata_pid = read_coordinator_metadata(workspace).map(|metadata| metadata.pid.get());
|
|
493
516
|
text.lines()
|
|
494
517
|
.filter_map(|line| parse_ps_command_line(line))
|
|
495
|
-
.filter(|(pid,
|
|
496
|
-
|
|
497
|
-
&&
|
|
518
|
+
.filter(|(pid, _command)| {
|
|
519
|
+
metadata_pid == Some(*pid)
|
|
520
|
+
&& *pid != std::process::id()
|
|
521
|
+
&& coordinator_pid_owned_by_workspace(workspace, Pid::new(*pid))
|
|
498
522
|
})
|
|
499
523
|
.map(|(pid, _)| Pid::new(pid))
|
|
500
524
|
.collect()
|
|
501
525
|
}
|
|
502
526
|
|
|
503
|
-
fn
|
|
504
|
-
let
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
Some((pid
|
|
527
|
+
fn coordinator_pid_owned_by_workspace(workspace: &WorkspacePath, pid: Pid) -> bool {
|
|
528
|
+
let workspace = workspace
|
|
529
|
+
.as_path()
|
|
530
|
+
.canonicalize()
|
|
531
|
+
.unwrap_or_else(|_| workspace.as_path().to_path_buf());
|
|
532
|
+
let Some(tokens) = crate::platform::argv::argv_tokens(pid.get()) else {
|
|
533
|
+
return false;
|
|
534
|
+
};
|
|
535
|
+
let is_team_agent = tokens.first().is_some_and(|token| is_team_agent_binary(token));
|
|
536
|
+
let is_coordinator = tokens.iter().any(|token| token == "coordinator");
|
|
537
|
+
let has_workspace = tokens.windows(2).any(|pair| {
|
|
538
|
+
pair[0] == "--workspace"
|
|
539
|
+
&& Path::new(&pair[1])
|
|
540
|
+
.canonicalize()
|
|
541
|
+
.map(|path| path == workspace)
|
|
542
|
+
.unwrap_or(false)
|
|
543
|
+
});
|
|
544
|
+
is_team_agent && is_coordinator && has_workspace
|
|
509
545
|
}
|
|
510
546
|
|
|
511
|
-
fn
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
547
|
+
fn terminate_pid_for_workspace(pid: Pid, workspace: &Path) -> bool {
|
|
548
|
+
if !coordinator_pid_owned_by_path(workspace, pid) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
let pids = process_tree_pids(pid)
|
|
552
|
+
.into_iter()
|
|
553
|
+
.filter(|candidate| coordinator_pid_owned_by_path(workspace, *candidate))
|
|
554
|
+
.collect::<Vec<_>>();
|
|
555
|
+
if pids.is_empty() {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
for child in pids.iter().rev() {
|
|
559
|
+
let _ = crate::platform::process::terminate_pid(
|
|
560
|
+
child.get(),
|
|
561
|
+
crate::platform::process::SignalKind::TerminateGraceful,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if !wait_until_all_not_running(&pids, Duration::from_secs(5)) {
|
|
565
|
+
for child in pids.iter().rev() {
|
|
566
|
+
let _ = crate::platform::process::terminate_pid(
|
|
567
|
+
child.get(),
|
|
568
|
+
crate::platform::process::SignalKind::TerminateForce,
|
|
569
|
+
);
|
|
517
570
|
}
|
|
518
571
|
}
|
|
519
|
-
|
|
572
|
+
wait_until_all_not_running(&pids, Duration::from_secs(5))
|
|
520
573
|
}
|
|
521
574
|
|
|
522
|
-
fn
|
|
523
|
-
|
|
524
|
-
.
|
|
525
|
-
.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
575
|
+
fn coordinator_pid_owned_by_path(workspace: &Path, pid: Pid) -> bool {
|
|
576
|
+
let workspace = workspace
|
|
577
|
+
.canonicalize()
|
|
578
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
579
|
+
let Some(tokens) = crate::platform::argv::argv_tokens(pid.get()) else {
|
|
580
|
+
return false;
|
|
581
|
+
};
|
|
582
|
+
let is_team_agent = tokens.first().is_some_and(|token| is_team_agent_binary(token));
|
|
583
|
+
let is_coordinator = tokens.iter().any(|token| token == "coordinator");
|
|
584
|
+
let has_workspace = tokens.windows(2).any(|pair| {
|
|
585
|
+
pair[0] == "--workspace"
|
|
586
|
+
&& Path::new(&pair[1])
|
|
587
|
+
.canonicalize()
|
|
588
|
+
.map(|path| path == workspace)
|
|
589
|
+
.unwrap_or(false)
|
|
590
|
+
});
|
|
591
|
+
is_team_agent && is_coordinator && has_workspace
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
fn is_team_agent_binary(token: &str) -> bool {
|
|
595
|
+
let Some(name) = Path::new(token).file_name().and_then(|name| name.to_str()) else {
|
|
596
|
+
return false;
|
|
597
|
+
};
|
|
598
|
+
name == "team-agent" || name == "team_agent" || name.starts_with("team-agent-")
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
|
|
602
|
+
let line = line.trim_start();
|
|
603
|
+
let split = line.find(char::is_whitespace).unwrap_or(line.len());
|
|
604
|
+
let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
|
|
605
|
+
let command = line.get(split..)?.trim();
|
|
606
|
+
Some((pid, command))
|
|
533
607
|
}
|
|
534
608
|
|
|
535
609
|
fn terminate_pid(pid: Pid) -> bool {
|
|
@@ -1004,6 +1078,34 @@ pub(crate) fn message_store_schema_health(workspace: &WorkspacePath) -> SchemaHe
|
|
|
1004
1078
|
}
|
|
1005
1079
|
}
|
|
1006
1080
|
|
|
1081
|
+
fn message_store_schema_health_read_only(workspace: &WorkspacePath) -> SchemaHealth {
|
|
1082
|
+
let action = Some("run team-agent doctor --fix-schema --json".to_string());
|
|
1083
|
+
match crate::db::migration::schema_diagnosis_workspace_read_only(workspace.as_path()) {
|
|
1084
|
+
Ok(diagnosis) if diagnosis.ok && diagnosis.status == "ok" => SchemaHealth {
|
|
1085
|
+
ok: true,
|
|
1086
|
+
schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
1087
|
+
error: None,
|
|
1088
|
+
action: None,
|
|
1089
|
+
},
|
|
1090
|
+
Ok(diagnosis) => SchemaHealth {
|
|
1091
|
+
ok: false,
|
|
1092
|
+
schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
1093
|
+
error: Some(SchemaError::InitFailed {
|
|
1094
|
+
message: format!("team.db status: {}", diagnosis.status),
|
|
1095
|
+
}),
|
|
1096
|
+
action,
|
|
1097
|
+
},
|
|
1098
|
+
Err(error) => SchemaHealth {
|
|
1099
|
+
ok: false,
|
|
1100
|
+
schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
1101
|
+
error: Some(SchemaError::InitFailed {
|
|
1102
|
+
message: error.to_string(),
|
|
1103
|
+
}),
|
|
1104
|
+
action,
|
|
1105
|
+
},
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1007
1109
|
fn read_pid_file(path: &Path) -> Option<Pid> {
|
|
1008
1110
|
let text = std::fs::read_to_string(path).ok()?;
|
|
1009
1111
|
let raw = text.trim().parse::<u32>().ok()?;
|
|
@@ -160,6 +160,40 @@ fn coordinator_team_scoped_state(
|
|
|
160
160
|
crate::state::projection::select_runtime_state(workspace, Some(selected)).ok()
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
fn live_sibling_team_state(
|
|
164
|
+
state: &Value,
|
|
165
|
+
missing_session: &str,
|
|
166
|
+
transport: &dyn crate::transport::Transport,
|
|
167
|
+
) -> Result<Option<(String, Value)>, crate::transport::TransportError> {
|
|
168
|
+
let Some(teams) = state.get("teams").and_then(Value::as_object) else {
|
|
169
|
+
return Ok(None);
|
|
170
|
+
};
|
|
171
|
+
for (team_key, team_state) in teams {
|
|
172
|
+
let status = team_state
|
|
173
|
+
.get("status")
|
|
174
|
+
.and_then(Value::as_str)
|
|
175
|
+
.map(|status| status.to_ascii_lowercase());
|
|
176
|
+
if status.is_some_and(|status| status != "alive" && status != "running") {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let Some(session_name) = team_state
|
|
180
|
+
.get("session_name")
|
|
181
|
+
.and_then(Value::as_str)
|
|
182
|
+
.filter(|session| !session.is_empty() && *session != missing_session)
|
|
183
|
+
else {
|
|
184
|
+
continue;
|
|
185
|
+
};
|
|
186
|
+
let session = crate::transport::SessionName::new(session_name);
|
|
187
|
+
if transport.has_session(&session)? {
|
|
188
|
+
return Ok(Some((
|
|
189
|
+
team_key.clone(),
|
|
190
|
+
crate::state::projection::project_top_level_view(state, team_key),
|
|
191
|
+
)));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
Ok(None)
|
|
195
|
+
}
|
|
196
|
+
|
|
163
197
|
// ===========================================================================
|
|
164
198
|
// Coordinator struct(daemon lifecycle + tick orchestration)
|
|
165
199
|
// ===========================================================================
|
|
@@ -330,20 +364,40 @@ impl Coordinator {
|
|
|
330
364
|
.get("session_name")
|
|
331
365
|
.and_then(Value::as_str)
|
|
332
366
|
.filter(|s| !s.is_empty())
|
|
367
|
+
.map(str::to_owned)
|
|
333
368
|
{
|
|
334
|
-
let session = crate::transport::SessionName::new(session_name);
|
|
369
|
+
let session = crate::transport::SessionName::new(&session_name);
|
|
335
370
|
if !self.transport.has_session(&session)? {
|
|
336
371
|
event_log.write(
|
|
337
372
|
"coordinator.session_missing",
|
|
338
373
|
serde_json::json!({"session": session_name}),
|
|
339
374
|
)?;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
375
|
+
if let Some((sibling_team, sibling_state)) =
|
|
376
|
+
live_sibling_team_state(&state, &session_name, self.transport.as_ref())?
|
|
377
|
+
{
|
|
378
|
+
event_log.write(
|
|
379
|
+
"coordinator.session_missing_sibling_alive",
|
|
380
|
+
serde_json::json!({
|
|
381
|
+
"session": session_name,
|
|
382
|
+
"sibling_team": sibling_team,
|
|
383
|
+
"action": "continue_with_sibling_team",
|
|
384
|
+
}),
|
|
385
|
+
)?;
|
|
386
|
+
state = sibling_state;
|
|
387
|
+
} else {
|
|
388
|
+
notify_session_missing(
|
|
389
|
+
self.workspace.as_path(),
|
|
390
|
+
&state,
|
|
391
|
+
&event_log,
|
|
392
|
+
&session_name,
|
|
393
|
+
)?;
|
|
394
|
+
return Ok(empty_tick_report(
|
|
395
|
+
false,
|
|
396
|
+
true,
|
|
397
|
+
Some(TickStopReason::TmuxSessionMissing),
|
|
398
|
+
None,
|
|
399
|
+
));
|
|
400
|
+
}
|
|
347
401
|
}
|
|
348
402
|
}
|
|
349
403
|
|
|
@@ -164,6 +164,8 @@ pub enum StopOutcome {
|
|
|
164
164
|
Missing,
|
|
165
165
|
/// pid 非整数 → 清文件返回(`lifecycle.py:238`)。
|
|
166
166
|
InvalidPidRemoved,
|
|
167
|
+
/// The pid file points to a live process owned by another workspace.
|
|
168
|
+
NotOwned,
|
|
167
169
|
/// SIGTERM 失败(`lifecycle.py:243`)。
|
|
168
170
|
KillFailed,
|
|
169
171
|
/// SIGTERM + 清 pid/meta 成功(`lifecycle.py:247`)。
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
use std::path::{Path, PathBuf};
|
|
10
10
|
|
|
11
|
-
use rusqlite::Connection;
|
|
11
|
+
use rusqlite::{Connection, OpenFlags};
|
|
12
12
|
|
|
13
13
|
use crate::db::schema::{ensure_schema_indexes, table_layout, SCHEMA_VERSION};
|
|
14
14
|
use crate::db::DbError;
|
|
@@ -423,22 +423,33 @@ pub fn ensure_table_layout(
|
|
|
423
423
|
/// `schema_migration.py:schema_diagnosis`:只读判定(不变更 DB)。
|
|
424
424
|
fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis, DbError> {
|
|
425
425
|
if !db_path.exists() {
|
|
426
|
-
|
|
427
|
-
// first-use state — ok:true is layered with the explicit status axis and the
|
|
428
|
-
// recommended_action guidance (Python schema_migration.py:180-190 verbatim),
|
|
429
|
-
// never a silent fake-green.
|
|
430
|
-
return Ok(Diagnosis {
|
|
431
|
-
ok: true,
|
|
432
|
-
status: "missing".to_string(),
|
|
433
|
-
user_version: 0,
|
|
434
|
-
layout_diffs: vec![],
|
|
435
|
-
recommended_action:
|
|
436
|
-
"No team.db exists yet; initialize_schema will create it on first use.".to_string(),
|
|
437
|
-
});
|
|
426
|
+
return Ok(missing_schema_diagnosis());
|
|
438
427
|
}
|
|
439
428
|
let conn = Connection::open(db_path)?;
|
|
440
|
-
|
|
441
|
-
|
|
429
|
+
schema_diagnosis_from_connection(&conn, schema_version)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fn missing_schema_diagnosis() -> Diagnosis {
|
|
433
|
+
// T3-3 cr verdict (A parity lock, 2026-06-10): a missing db is the LEGAL
|
|
434
|
+
// first-use state — ok:true is layered with the explicit status axis and the
|
|
435
|
+
// recommended_action guidance (Python schema_migration.py:180-190 verbatim),
|
|
436
|
+
// never a silent fake-green.
|
|
437
|
+
Diagnosis {
|
|
438
|
+
ok: true,
|
|
439
|
+
status: "missing".to_string(),
|
|
440
|
+
user_version: 0,
|
|
441
|
+
layout_diffs: vec![],
|
|
442
|
+
recommended_action:
|
|
443
|
+
"No team.db exists yet; initialize_schema will create it on first use.".to_string(),
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fn schema_diagnosis_from_connection(
|
|
448
|
+
conn: &Connection,
|
|
449
|
+
schema_version: i64,
|
|
450
|
+
) -> Result<Diagnosis, DbError> {
|
|
451
|
+
let uv = pragma_user_version(conn)?;
|
|
452
|
+
let diffs = layout_diffs(conn)?;
|
|
442
453
|
let diff_tables: Vec<String> = diffs.iter().map(|d| d.table.to_string()).collect();
|
|
443
454
|
let ok = diffs.is_empty() && uv == schema_version;
|
|
444
455
|
Ok(Diagnosis {
|
|
@@ -466,6 +477,16 @@ pub fn schema_diagnosis_workspace(workspace: &Path) -> Result<Diagnosis, DbError
|
|
|
466
477
|
)
|
|
467
478
|
}
|
|
468
479
|
|
|
480
|
+
/// 对 workspace 的 team.db 执行严格只读 diagnosis,不创建或初始化缺失/空文件。
|
|
481
|
+
pub fn schema_diagnosis_workspace_read_only(workspace: &Path) -> Result<Diagnosis, DbError> {
|
|
482
|
+
let db_path = workspace.join(".team").join("runtime").join("team.db");
|
|
483
|
+
if !db_path.exists() {
|
|
484
|
+
return Ok(missing_schema_diagnosis());
|
|
485
|
+
}
|
|
486
|
+
let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
|
487
|
+
schema_diagnosis_from_connection(&conn, SCHEMA_VERSION)
|
|
488
|
+
}
|
|
489
|
+
|
|
469
490
|
/// `schema_migration.py:_workspace_from_db_path`:`<ws>/.team/runtime/team.db` → `<ws>`。
|
|
470
491
|
fn workspace_from_db_path(db_path: &Path) -> Option<PathBuf> {
|
|
471
492
|
let parts: Vec<_> = db_path.iter().collect();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
use std::path::Path;
|
|
4
4
|
|
|
5
|
-
use serde_json::json;
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
6
|
|
|
7
7
|
use crate::event_log::{EventLog, EventLogError};
|
|
8
8
|
use crate::transport::{BackendKind, Transport};
|
|
@@ -29,6 +29,45 @@ pub(crate) fn pre_kill_audit(
|
|
|
29
29
|
"argv": argv,
|
|
30
30
|
"endpoint": endpoint,
|
|
31
31
|
"targets": targets,
|
|
32
|
+
"workspace": Value::Null,
|
|
33
|
+
"team": Value::Null,
|
|
34
|
+
"generation": Value::Null,
|
|
35
|
+
"owner_evidence": "legacy_unscoped",
|
|
36
|
+
"reason": "legacy_callsite",
|
|
37
|
+
}),
|
|
38
|
+
)?;
|
|
39
|
+
Ok(())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub(crate) fn pre_kill_audit_scoped(
|
|
43
|
+
event_log: &EventLog,
|
|
44
|
+
transport: &dyn Transport,
|
|
45
|
+
caller: &str,
|
|
46
|
+
action: &str,
|
|
47
|
+
targets: &[String],
|
|
48
|
+
workspace: &Path,
|
|
49
|
+
team: Option<&str>,
|
|
50
|
+
generation: Option<&str>,
|
|
51
|
+
owner_evidence: &str,
|
|
52
|
+
reason: &str,
|
|
53
|
+
) -> Result<(), EventLogError> {
|
|
54
|
+
let endpoint = transport.tmux_endpoint();
|
|
55
|
+
let argv = destructive_argv(transport.kind(), endpoint.as_deref(), action, targets);
|
|
56
|
+
event_log.write(
|
|
57
|
+
"transport.pre_kill_audit",
|
|
58
|
+
json!({
|
|
59
|
+
"phase": "pre_call",
|
|
60
|
+
"caller": caller,
|
|
61
|
+
"caller_pid": std::process::id(),
|
|
62
|
+
"action": action,
|
|
63
|
+
"argv": argv,
|
|
64
|
+
"endpoint": endpoint,
|
|
65
|
+
"targets": targets,
|
|
66
|
+
"workspace": workspace.to_string_lossy(),
|
|
67
|
+
"team": team,
|
|
68
|
+
"generation": generation,
|
|
69
|
+
"owner_evidence": owner_evidence,
|
|
70
|
+
"reason": reason,
|
|
32
71
|
}),
|
|
33
72
|
)?;
|
|
34
73
|
Ok(())
|
|
@@ -57,7 +96,7 @@ fn destructive_argv(
|
|
|
57
96
|
vec![format!("{kind:?}").to_ascii_lowercase()]
|
|
58
97
|
};
|
|
59
98
|
argv.push(action.to_string());
|
|
60
|
-
if action == KILL_SESSION {
|
|
99
|
+
if action == KILL_SESSION || action == "kill-pane" {
|
|
61
100
|
for target in targets {
|
|
62
101
|
argv.push("-t".to_string());
|
|
63
102
|
argv.push(target.clone());
|
|
@@ -124,11 +124,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
127
|
-
self.transport
|
|
128
|
-
.query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
|
|
129
|
-
.ok()
|
|
130
|
-
.flatten()
|
|
131
|
-
.filter(|command| !command.trim().is_empty())
|
|
127
|
+
query_caller_pane_command(self.transport, pane)
|
|
132
128
|
}
|
|
133
129
|
|
|
134
130
|
fn attach(
|
|
@@ -142,7 +138,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
142
138
|
self.applied_grant = None;
|
|
143
139
|
self.registry_receipt = None;
|
|
144
140
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
145
|
-
let targets = match self.transport
|
|
141
|
+
let targets = match list_caller_targets(self.transport) {
|
|
146
142
|
Ok(targets) => targets,
|
|
147
143
|
Err(_) => {
|
|
148
144
|
self.last_failure_reason = Some("caller_pane_unobservable");
|
|
@@ -290,11 +286,19 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
290
286
|
self.last_failure_reason = Some("receiver_missing");
|
|
291
287
|
return false;
|
|
292
288
|
};
|
|
289
|
+
let leader_transport = receiver
|
|
290
|
+
.get("tmux_socket")
|
|
291
|
+
.and_then(serde_json::Value::as_str)
|
|
292
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
293
|
+
.map(crate::transport_factory::leader_endpoint_transport);
|
|
294
|
+
let live_channel_transport: &dyn Transport = leader_transport
|
|
295
|
+
.as_deref()
|
|
296
|
+
.unwrap_or(self.transport);
|
|
293
297
|
if !matches!(
|
|
294
298
|
crate::messaging::resolve_live_leader_channel(
|
|
295
299
|
workspace,
|
|
296
300
|
receiver,
|
|
297
|
-
|
|
301
|
+
live_channel_transport,
|
|
298
302
|
),
|
|
299
303
|
crate::messaging::LeaderChannelResolution::Live(_)
|
|
300
304
|
) {
|
|
@@ -875,6 +879,42 @@ fn existing_runtime_identity_view(
|
|
|
875
879
|
state.clone()
|
|
876
880
|
}
|
|
877
881
|
|
|
882
|
+
fn caller_read_transport() -> Option<Box<dyn Transport>> {
|
|
883
|
+
let endpoint = crate::tmux_backend::socket_name_from_tmux_env()?;
|
|
884
|
+
// Unit tests use an injected transport even when they seed a synthetic TMUX
|
|
885
|
+
// tuple. Never make those hermetic tests depend on a host tmux server; the
|
|
886
|
+
// production path below remains a read-only endpoint probe.
|
|
887
|
+
#[cfg(test)]
|
|
888
|
+
if !Path::new(&endpoint).exists() {
|
|
889
|
+
return None;
|
|
890
|
+
}
|
|
891
|
+
Some(Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(
|
|
892
|
+
&endpoint,
|
|
893
|
+
)))
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
fn query_caller_pane_command(transport: &dyn Transport, pane: &PaneId) -> Option<String> {
|
|
897
|
+
let caller_transport = caller_read_transport();
|
|
898
|
+
let query_transport: &dyn Transport = caller_transport
|
|
899
|
+
.as_deref()
|
|
900
|
+
.unwrap_or(transport);
|
|
901
|
+
query_transport
|
|
902
|
+
.query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
|
|
903
|
+
.ok()
|
|
904
|
+
.flatten()
|
|
905
|
+
.filter(|command| !command.trim().is_empty())
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
fn list_caller_targets(
|
|
909
|
+
transport: &dyn Transport,
|
|
910
|
+
) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
|
|
911
|
+
if let Some(caller_transport) = caller_read_transport() {
|
|
912
|
+
caller_transport.list_targets()
|
|
913
|
+
} else {
|
|
914
|
+
transport.list_targets()
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
878
918
|
fn preflight_fresh_leader_identity(
|
|
879
919
|
transport: &dyn Transport,
|
|
880
920
|
) -> Option<(String, Vec<String>, Vec<String>)> {
|
|
@@ -900,13 +940,7 @@ fn preflight_fresh_leader_identity(
|
|
|
900
940
|
let Some(pane) = pane else {
|
|
901
941
|
return None;
|
|
902
942
|
};
|
|
903
|
-
let command = transport
|
|
904
|
-
.query(
|
|
905
|
-
&Target::Pane(PaneId::new(pane)),
|
|
906
|
-
PaneField::PaneCurrentCommand,
|
|
907
|
-
)
|
|
908
|
-
.ok()
|
|
909
|
-
.flatten()
|
|
943
|
+
let command = query_caller_pane_command(transport, &PaneId::new(pane))
|
|
910
944
|
.unwrap_or_default();
|
|
911
945
|
let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
912
946
|
.ok()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//! contract:
|
|
4
4
|
//! provides:
|
|
5
5
|
//! - name: quick_start_tmux_backend
|
|
6
|
-
//! what:
|
|
6
|
+
//! what: 按 workspace 派生私有 socket;调用方 tmux 只用于 leader receiver 绑定
|
|
7
7
|
//! - name: annotate_runtime_transport
|
|
8
8
|
//! what: 把 transport 种类与来源写进 state,tmux 时再写 endpoint 三字段
|
|
9
9
|
//! - name: attach_commands_for_runtime_windows
|
|
@@ -37,15 +37,11 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
|
|
|
37
37
|
use super::*;
|
|
38
38
|
|
|
39
39
|
/// ---
|
|
40
|
-
/// purpose: 定出 quick-start
|
|
41
|
-
/// returns:
|
|
40
|
+
/// purpose: 定出 quick-start 使用的私有 tmux 后端
|
|
41
|
+
/// returns: 始终按 workspace 派生 endpoint;调用方 $TMUX 仅供 leader receiver 身份绑定
|
|
42
42
|
/// ---
|
|
43
43
|
pub(crate) fn quick_start_tmux_backend(workspace: &Path) -> crate::tmux_backend::TmuxBackend {
|
|
44
|
-
|
|
45
|
-
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
|
|
46
|
-
} else {
|
|
47
|
-
crate::tmux_backend::TmuxBackend::for_workspace(workspace)
|
|
48
|
-
}
|
|
44
|
+
crate::tmux_backend::TmuxBackend::for_workspace(workspace)
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
/// ---
|
|
@@ -146,6 +146,23 @@ pub fn launch_with_transport_in_workspace(
|
|
|
146
146
|
transport,
|
|
147
147
|
&started,
|
|
148
148
|
)?;
|
|
149
|
+
let owner_team = load_runtime_state(workspace)
|
|
150
|
+
.ok()
|
|
151
|
+
.map(|state| crate::state::projection::team_state_key(&state))
|
|
152
|
+
.filter(|team| !team.is_empty())
|
|
153
|
+
.unwrap_or_else(|| session_name.as_str().to_string());
|
|
154
|
+
let generation = started
|
|
155
|
+
.first()
|
|
156
|
+
.map(|agent| agent.spawned_at.as_str())
|
|
157
|
+
.unwrap_or(session_name.as_str());
|
|
158
|
+
transport
|
|
159
|
+
.set_session_owner_with_generation(
|
|
160
|
+
&session_name,
|
|
161
|
+
workspace,
|
|
162
|
+
&owner_team,
|
|
163
|
+
generation,
|
|
164
|
+
)
|
|
165
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
149
166
|
// 0.5.38: per-worker timing tags (source="launch") so operators can
|
|
150
167
|
// trace which worker's spawn dominates wall time. Zeros for now on
|
|
151
168
|
// the sub-timings — Step 1 first enables shape assertion; a later
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
mod hermetic_guard;
|
|
3
3
|
|
|
4
4
|
use super::*;
|
|
5
|
+
use crate::tmux_backend::TmuxBackend;
|
|
5
6
|
use crate::transport::test_support::OfflineTransport;
|
|
6
7
|
use serde_json::json;
|
|
7
8
|
use serial_test::serial;
|
|
@@ -1428,19 +1429,21 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
|
|
|
1428
1429
|
|
|
1429
1430
|
#[test]
|
|
1430
1431
|
#[serial(env)]
|
|
1431
|
-
fn
|
|
1432
|
+
fn quick_start_tmux_backend_uses_workspace_private_endpoint() {
|
|
1432
1433
|
let leader_socket = "/tmp/team-agent-layout-leader-socket";
|
|
1433
1434
|
let _tmux = EnvVarGuard::set("TMUX", &format!("{leader_socket},123,0"));
|
|
1434
|
-
let
|
|
1435
|
+
let workspace = std::path::Path::new("/tmp/layout-workspace");
|
|
1436
|
+
let backend = quick_start_tmux_backend(workspace);
|
|
1437
|
+
let expected = TmuxBackend::for_workspace(workspace);
|
|
1435
1438
|
|
|
1436
1439
|
assert_eq!(
|
|
1437
|
-
crate::transport::Transport::tmux_endpoint(&backend)
|
|
1438
|
-
|
|
1439
|
-
"quick-start
|
|
1440
|
+
crate::transport::Transport::tmux_endpoint(&backend),
|
|
1441
|
+
crate::transport::Transport::tmux_endpoint(&expected),
|
|
1442
|
+
"quick-start workers must use the workspace-private socket, not ambient $TMUX"
|
|
1440
1443
|
);
|
|
1441
1444
|
assert_eq!(
|
|
1442
|
-
selected_tmux_socket_source(&backend,
|
|
1443
|
-
Some("
|
|
1445
|
+
selected_tmux_socket_source(&backend, workspace),
|
|
1446
|
+
Some("workspace")
|
|
1444
1447
|
);
|
|
1445
1448
|
}
|
|
1446
1449
|
|