@team-agent/installer 0.5.92 → 0.5.97
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 +381 -157
- package/crates/team-agent/src/cli/diagnose.rs +40 -10
- package/crates/team-agent/src/cli/emit.rs +6 -4
- package/crates/team-agent/src/cli/mod.rs +483 -470
- package/crates/team-agent/src/cli/models.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +20 -10
- package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
- package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +197 -30
- package/crates/team-agent/src/cli/triage.rs +31 -31
- package/crates/team-agent/src/cli/types.rs +3 -1
- package/crates/team-agent/src/coordinator/health.rs +92 -30
- 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/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/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
- 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/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
- package/crates/team-agent/src/platform/process.rs +4 -45
- package/crates/team-agent/src/tmux_backend.rs +113 -3
- 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
|
@@ -332,6 +332,7 @@ pub fn start_coordinator_with_team(
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
command
|
|
335
|
+
.current_dir(workspace.as_path())
|
|
335
336
|
.stdin(Stdio::null())
|
|
336
337
|
.stdout(Stdio::from(log))
|
|
337
338
|
.stderr(Stdio::from(log_err));
|
|
@@ -432,6 +433,16 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
432
433
|
pid: Some(pid),
|
|
433
434
|
});
|
|
434
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
|
+
}
|
|
435
446
|
if pid.get() == std::process::id() {
|
|
436
447
|
remove_file_if_exists(&pid_path)?;
|
|
437
448
|
remove_file_if_exists(&coordinator_meta_path(workspace))?;
|
|
@@ -441,7 +452,7 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
441
452
|
pid: Some(pid),
|
|
442
453
|
});
|
|
443
454
|
}
|
|
444
|
-
if !
|
|
455
|
+
if !terminate_pid_for_workspace(pid, workspace.as_path()) {
|
|
445
456
|
return Ok(StopReport {
|
|
446
457
|
ok: false,
|
|
447
458
|
status: StopOutcome::KillFailed,
|
|
@@ -468,7 +479,7 @@ fn stop_discovered_coordinators(
|
|
|
468
479
|
let mut stopped = None;
|
|
469
480
|
let mut failed = None;
|
|
470
481
|
for pid in pids {
|
|
471
|
-
if
|
|
482
|
+
if terminate_pid_for_workspace(pid, workspace.as_path()) {
|
|
472
483
|
stopped.get_or_insert(pid);
|
|
473
484
|
} else {
|
|
474
485
|
failed.get_or_insert(pid);
|
|
@@ -501,47 +512,98 @@ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
|
|
|
501
512
|
_ => return Vec::new(),
|
|
502
513
|
};
|
|
503
514
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
504
|
-
let
|
|
515
|
+
let metadata_pid = read_coordinator_metadata(workspace).map(|metadata| metadata.pid.get());
|
|
505
516
|
text.lines()
|
|
506
517
|
.filter_map(|line| parse_ps_command_line(line))
|
|
507
|
-
.filter(|(pid,
|
|
508
|
-
|
|
509
|
-
&&
|
|
518
|
+
.filter(|(pid, _command)| {
|
|
519
|
+
metadata_pid == Some(*pid)
|
|
520
|
+
&& *pid != std::process::id()
|
|
521
|
+
&& coordinator_pid_owned_by_workspace(workspace, Pid::new(*pid))
|
|
510
522
|
})
|
|
511
523
|
.map(|(pid, _)| Pid::new(pid))
|
|
512
524
|
.collect()
|
|
513
525
|
}
|
|
514
526
|
|
|
515
|
-
fn
|
|
516
|
-
let
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
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
|
|
521
545
|
}
|
|
522
546
|
|
|
523
|
-
fn
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
);
|
|
529
570
|
}
|
|
530
571
|
}
|
|
531
|
-
|
|
572
|
+
wait_until_all_not_running(&pids, Duration::from_secs(5))
|
|
532
573
|
}
|
|
533
574
|
|
|
534
|
-
fn
|
|
535
|
-
|
|
536
|
-
.
|
|
537
|
-
.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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))
|
|
545
607
|
}
|
|
546
608
|
|
|
547
609
|
fn terminate_pid(pid: Pid) -> bool {
|
|
@@ -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`)。
|
|
@@ -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
|
|
@@ -1041,6 +1041,32 @@ fn restart_with_selected_team_and_transport(
|
|
|
1041
1041
|
attach_commands,
|
|
1042
1042
|
});
|
|
1043
1043
|
}
|
|
1044
|
+
// Respawn changes the generation used by shutdown's ownership check, even
|
|
1045
|
+
// when build-before-destroy keeps the tmux session itself alive. Stamp only
|
|
1046
|
+
// the selected session, after its replacement state is durable.
|
|
1047
|
+
if let Some(generation) = state
|
|
1048
|
+
.get("generation")
|
|
1049
|
+
.and_then(serde_json::Value::as_str)
|
|
1050
|
+
.or_else(|| {
|
|
1051
|
+
state
|
|
1052
|
+
.get("agents")
|
|
1053
|
+
.and_then(serde_json::Value::as_object)
|
|
1054
|
+
.and_then(|agents| {
|
|
1055
|
+
agents.values().find_map(|agent| {
|
|
1056
|
+
agent.get("spawned_at").and_then(serde_json::Value::as_str)
|
|
1057
|
+
})
|
|
1058
|
+
})
|
|
1059
|
+
})
|
|
1060
|
+
{
|
|
1061
|
+
transport
|
|
1062
|
+
.set_session_owner_with_generation(
|
|
1063
|
+
&session_name,
|
|
1064
|
+
&selected.run_workspace,
|
|
1065
|
+
&selected.team_key,
|
|
1066
|
+
generation,
|
|
1067
|
+
)
|
|
1068
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
1069
|
+
}
|
|
1044
1070
|
// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
|
|
1045
1071
|
// resume backing validation.
|
|
1046
1072
|
//
|
|
@@ -168,8 +168,10 @@ fn launch_restart_start_add_and_fork_all_delegate_to_the_profile_launch_resolver
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
#[test]
|
|
171
|
+
#[serial_test::serial(env)]
|
|
171
172
|
fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
|
|
172
|
-
let
|
|
173
|
+
let env = hermetic_guard::HermeticTestEnv::enter("diagnose-missing-profile");
|
|
174
|
+
let ws = env.workspace("missing-profile");
|
|
173
175
|
let team = write_claude_profile_team(&ws, "diagteam", "clauder");
|
|
174
176
|
std::fs::remove_file(
|
|
175
177
|
ws.join(".team")
|
|
@@ -180,7 +182,7 @@ fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
|
|
|
180
182
|
.unwrap();
|
|
181
183
|
|
|
182
184
|
let output = Command::new(crate::lifecycle::tests::test_binary_path())
|
|
183
|
-
.args(["diagnose", "--workspace", ws.to_str().unwrap(), "--json"])
|
|
185
|
+
.args(["diagnose", team.to_str().unwrap(), "--workspace", ws.to_str().unwrap(), "--json"])
|
|
184
186
|
.current_dir(&team)
|
|
185
187
|
.output()
|
|
186
188
|
.expect("diagnose subprocess should run");
|
|
@@ -197,10 +199,14 @@ fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
|
|
|
197
199
|
json!(false),
|
|
198
200
|
"diagnose missing profile must emit ok:false JSON; stdout={stdout} stderr={stderr}"
|
|
199
201
|
);
|
|
202
|
+
assert_eq!(json["profile_smoke"]["ok"], false, "{json}");
|
|
200
203
|
assert!(
|
|
201
|
-
|
|
202
|
-
"diagnose missing profile must
|
|
204
|
+
json["profile_smoke"].to_string().contains("profile"),
|
|
205
|
+
"diagnose missing profile must identify the scoped configuration failure: {json}"
|
|
203
206
|
);
|
|
207
|
+
assert!(json["providers"].as_object().unwrap().values().all(|provider| {
|
|
208
|
+
provider["probe_status"] == "not_run"
|
|
209
|
+
}));
|
|
204
210
|
assert!(
|
|
205
211
|
!stdout.contains("tokens") && !stderr.contains("tokens"),
|
|
206
212
|
"diagnose dry-run must not consume provider tokens or report token usage; stdout={stdout} stderr={stderr}"
|
|
@@ -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
|
|