@team-agent/installer 0.5.77 → 0.5.80
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 +161 -29
- package/crates/team-agent/src/cli/diagnose.rs +333 -68
- package/crates/team-agent/src/cli/emit.rs +24 -8
- package/crates/team-agent/src/cli/mod.rs +543 -98
- package/crates/team-agent/src/cli/models.rs +393 -32
- package/crates/team-agent/src/cli/named_address.rs +19 -5
- package/crates/team-agent/src/cli/profile.rs +35 -4
- package/crates/team-agent/src/cli/spec.rs +7 -2
- package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
- package/crates/team-agent/src/cli/status_port/store.rs +1 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
- package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/leader/lease.rs +492 -33
- package/crates/team-agent/src/leader/owner_bind.rs +40 -12
- package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
- package/crates/team-agent/src/leader/rediscover.rs +36 -6
- package/crates/team-agent/src/leader/start.rs +197 -88
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
- package/crates/team-agent/src/lifecycle/launch.rs +30 -7
- package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
- package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
- package/crates/team-agent/src/lifecycle/restart.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +140 -11
- package/crates/team-agent/src/messaging/delivery.rs +221 -80
- package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +102 -4
- package/crates/team-agent/src/state/projection.rs +7 -1
- package/crates/team-agent/src/state/repository.rs +29 -0
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
- package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
- package/crates/team-agent/src/tmux_backend.rs +239 -127
- package/crates/team-agent/src/transport.rs +78 -0
- package/crates/team-agent/src/transport_factory.rs +53 -0
- package/package.json +4 -5
- package/skills/team-agent/SKILL.md +45 -17
- package/skills/team-agent/command-coverage.json +4 -29
- package/npm/check-skill-links.mjs +0 -88
- package/skills/team-agent/references/team-agent-operator.md +0 -672
|
@@ -142,7 +142,28 @@ pub fn bind_owner_from_caller_pane(
|
|
|
142
142
|
.as_ref()
|
|
143
143
|
.and_then(|info| info.current_command.clone())
|
|
144
144
|
.unwrap_or_else(|| tmux_pane_current_command(workspace, &pane).unwrap_or_default());
|
|
145
|
-
let provider = bind_provider_from_env_or_pane(&caller_current_command, caller_info)
|
|
145
|
+
let Some(provider) = bind_provider_from_env_or_pane(&caller_current_command, caller_info) else {
|
|
146
|
+
let hint = "set TEAM_AGENT_LEADER_PROVIDER or run from a provider pane; unknown commands are not Codex";
|
|
147
|
+
event_log.write(
|
|
148
|
+
LeaderEvent::OwnerBindRefused.name(),
|
|
149
|
+
json!({
|
|
150
|
+
"reason": serde_json::to_value(LeaseReason::CallerNotLeaderShaped)?,
|
|
151
|
+
"caller_pane_id": pane,
|
|
152
|
+
"caller_current_command": caller_current_command.clone(),
|
|
153
|
+
"team_id": team_id.as_str(),
|
|
154
|
+
"hint": hint,
|
|
155
|
+
}),
|
|
156
|
+
)?;
|
|
157
|
+
return Ok(OwnerBindResult {
|
|
158
|
+
ok: false,
|
|
159
|
+
owner: None,
|
|
160
|
+
caller_pane_id: PaneId::new(pane),
|
|
161
|
+
caller_current_command,
|
|
162
|
+
team_id: team_id.clone(),
|
|
163
|
+
reason: Some(LeaseReason::CallerNotLeaderShaped),
|
|
164
|
+
hint: Some(hint.to_string()),
|
|
165
|
+
});
|
|
166
|
+
};
|
|
146
167
|
let machine_fingerprint = std::env::var("TEAM_AGENT_MACHINE_FINGERPRINT").unwrap_or_default();
|
|
147
168
|
let os_user = std::env::var("USER")
|
|
148
169
|
.or_else(|_| std::env::var("USERNAME"))
|
|
@@ -207,17 +228,13 @@ fn emit_owner_bound_event(
|
|
|
207
228
|
Ok(())
|
|
208
229
|
}
|
|
209
230
|
|
|
210
|
-
fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Provider {
|
|
231
|
+
fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Option<Provider> {
|
|
211
232
|
let env_provider = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
212
233
|
.ok()
|
|
213
234
|
.and_then(|raw| super::helpers::parse_provider(&raw));
|
|
214
235
|
env_provider
|
|
215
236
|
.or_else(|| pane.as_ref().and_then(super::attribute_pane_provider))
|
|
216
237
|
.or_else(|| super::attribute_command_provider(command))
|
|
217
|
-
// E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
|
|
218
|
-
// 无法识别时回落 Codex 仅作最末兜底,且该路径已被统一归因入口的显式 None 收窄
|
|
219
|
-
// (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
|
|
220
|
-
.unwrap_or(Provider::Codex)
|
|
221
238
|
}
|
|
222
239
|
|
|
223
240
|
pub(crate) fn strict_owner_bind_provider(
|
|
@@ -352,13 +369,8 @@ mod e11_provider_bind_tests {
|
|
|
352
369
|
}
|
|
353
370
|
|
|
354
371
|
#[test]
|
|
355
|
-
fn
|
|
356
|
-
assert_eq!(
|
|
357
|
-
strict_owner_bind_provider(Some("pi"), "codex"),
|
|
358
|
-
Some(Provider::Pi)
|
|
359
|
-
);
|
|
372
|
+
fn provider_independent_rejects_unknown_or_node_without_fallback() {
|
|
360
373
|
assert_eq!(strict_owner_bind_provider(Some("unknown"), "codex"), None);
|
|
361
|
-
assert_eq!(strict_owner_bind_provider(None, "pi"), Some(Provider::Pi));
|
|
362
374
|
assert_eq!(strict_owner_bind_provider(None, "node"), None);
|
|
363
375
|
}
|
|
364
376
|
|
|
@@ -394,3 +406,19 @@ mod e11_provider_bind_tests {
|
|
|
394
406
|
assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
|
|
395
407
|
}
|
|
396
408
|
}
|
|
409
|
+
|
|
410
|
+
#[cfg(test)]
|
|
411
|
+
mod claim_pi_carrier_tests {
|
|
412
|
+
use super::*;
|
|
413
|
+
|
|
414
|
+
#[test]
|
|
415
|
+
fn strict_provider_never_falls_back_from_unknown_explicit_or_command() {
|
|
416
|
+
assert_eq!(
|
|
417
|
+
strict_owner_bind_provider(Some("pi"), "codex"),
|
|
418
|
+
Some(Provider::Pi)
|
|
419
|
+
);
|
|
420
|
+
assert_eq!(strict_owner_bind_provider(Some("unknown"), "codex"), None);
|
|
421
|
+
assert_eq!(strict_owner_bind_provider(None, "pi"), Some(Provider::Pi));
|
|
422
|
+
assert_eq!(strict_owner_bind_provider(None, "node"), None);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
@@ -204,6 +204,71 @@ fn try_readopt_refuses_when_different_live_owner_uuid_mismatches() {
|
|
|
204
204
|
assert!(state.get("team_owner").is_none());
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
#[test]
|
|
208
|
+
fn try_readopt_inactive_bash_wrapper_with_matching_owner_uuid() {
|
|
209
|
+
let ws = ws("readopt-bash-ok");
|
|
210
|
+
let mut state = json!({"session_name": "sess"});
|
|
211
|
+
let uuid = "c1e3f7a7aeaed95491667b4d9c3ce6fb";
|
|
212
|
+
let mut recv = receiver("%0", uuid, 1);
|
|
213
|
+
let owner = owner("%0", uuid, 1);
|
|
214
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
215
|
+
let pane = json!({
|
|
216
|
+
"pane_id": "%0",
|
|
217
|
+
"session_name": "sess",
|
|
218
|
+
"window_name": "pi",
|
|
219
|
+
"pane_current_command": "bash",
|
|
220
|
+
"pane_current_path": ws.join("sub").to_string_lossy(),
|
|
221
|
+
"active": false,
|
|
222
|
+
"leader_session_uuid": uuid,
|
|
223
|
+
"leader_env": {
|
|
224
|
+
"TEAM_AGENT_LEADER_SESSION_UUID": uuid,
|
|
225
|
+
"TEAM_AGENT_WORKSPACE": ws.to_string_lossy(),
|
|
226
|
+
"TEAM_AGENT_TEAM_ID": "sess"
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
let got = try_readopt_leader_pane(
|
|
230
|
+
&ws,
|
|
231
|
+
&mut state,
|
|
232
|
+
&mut recv,
|
|
233
|
+
&pane,
|
|
234
|
+
&json!({"targets": [pane.clone()]}),
|
|
235
|
+
Some(&owner),
|
|
236
|
+
Provider::Codex,
|
|
237
|
+
LeaseSource::Manual,
|
|
238
|
+
&event_log,
|
|
239
|
+
)
|
|
240
|
+
.unwrap();
|
|
241
|
+
assert!(got.is_some(), "matching inactive wrapper must readopt");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#[test]
|
|
245
|
+
fn try_readopt_bash_without_recorded_owner_uuid_refuses() {
|
|
246
|
+
let ws = ws("readopt-bash-no-id");
|
|
247
|
+
let mut state = json!({});
|
|
248
|
+
let mut recv = receiver("%0", "c1e3f7a7aeaed95491667b4d9c3ce6fb", 1);
|
|
249
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
250
|
+
let pane = json!({
|
|
251
|
+
"pane_id": "%0",
|
|
252
|
+
"session_name": "sess",
|
|
253
|
+
"pane_current_command": "bash",
|
|
254
|
+
"pane_current_path": ws.join("sub").to_string_lossy(),
|
|
255
|
+
"active": false
|
|
256
|
+
});
|
|
257
|
+
let got = try_readopt_leader_pane(
|
|
258
|
+
&ws,
|
|
259
|
+
&mut state,
|
|
260
|
+
&mut recv,
|
|
261
|
+
&pane,
|
|
262
|
+
&json!({"targets": [pane.clone()]}),
|
|
263
|
+
None,
|
|
264
|
+
Provider::Codex,
|
|
265
|
+
LeaseSource::Manual,
|
|
266
|
+
&event_log,
|
|
267
|
+
)
|
|
268
|
+
.unwrap();
|
|
269
|
+
assert!(got.is_none());
|
|
270
|
+
}
|
|
271
|
+
|
|
207
272
|
#[test]
|
|
208
273
|
fn rediscover_updates_unique_env_triple_match() {
|
|
209
274
|
let ws = ws("rediscover");
|
|
@@ -42,7 +42,12 @@ pub fn try_readopt_leader_pane(
|
|
|
42
42
|
)?;
|
|
43
43
|
return Ok(None);
|
|
44
44
|
};
|
|
45
|
-
if !readopt_candidate_is_usable_leader(
|
|
45
|
+
if !readopt_candidate_is_usable_leader(
|
|
46
|
+
workspace,
|
|
47
|
+
&candidate,
|
|
48
|
+
owner_record,
|
|
49
|
+
receiver_provider,
|
|
50
|
+
) {
|
|
46
51
|
event_log.write(
|
|
47
52
|
LeaderEvent::ReceiverRebindRequired.name(),
|
|
48
53
|
json!({"reason": "leader_pane_unusable"}),
|
|
@@ -598,14 +603,39 @@ fn target_iter(targets: &Value) -> Vec<LeaderTarget> {
|
|
|
598
603
|
Vec::new()
|
|
599
604
|
}
|
|
600
605
|
|
|
601
|
-
fn readopt_candidate_is_usable_leader(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
+
fn readopt_candidate_is_usable_leader(
|
|
607
|
+
workspace: &Path,
|
|
608
|
+
target: &LeaderTarget,
|
|
609
|
+
owner_record: Option<&TeamOwner>,
|
|
610
|
+
receiver_provider: Provider,
|
|
611
|
+
) -> bool {
|
|
612
|
+
if !target
|
|
606
613
|
.current_path
|
|
607
614
|
.as_deref()
|
|
608
615
|
.is_some_and(|path| path_in_workspace(path, workspace))
|
|
616
|
+
{
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
if target.provider.is_some() {
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
let Some(owner) = owner_record else {
|
|
623
|
+
return false;
|
|
624
|
+
};
|
|
625
|
+
if owner.pane_id.as_str() != target.pane_id.as_str() {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
if owner.provider != receiver_provider {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
match (
|
|
632
|
+
owner.leader_session_uuid.as_ref(),
|
|
633
|
+
target.leader_session_uuid.as_ref(),
|
|
634
|
+
) {
|
|
635
|
+
(Some(recorded), Some(live)) => recorded == live,
|
|
636
|
+
(Some(_), None) => true,
|
|
637
|
+
(None, _) => false,
|
|
638
|
+
}
|
|
609
639
|
}
|
|
610
640
|
|
|
611
641
|
fn rediscover_candidate_is_usable_leader(target: &LeaderTarget) -> bool {
|
|
@@ -1412,13 +1412,6 @@ extern "C" fn managed_term_handler(_: libc::c_int) {
|
|
|
1412
1412
|
}
|
|
1413
1413
|
|
|
1414
1414
|
fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
|
|
1415
|
-
let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
|
|
1416
|
-
let target = Target::Pane(watch.spawned.pane_id.clone());
|
|
1417
|
-
if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
|
|
1418
|
-
if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
|
|
1419
|
-
return true;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
1415
|
matches!(
|
|
1423
1416
|
leader_provider_health(
|
|
1424
1417
|
watch.transport,
|
|
@@ -1674,65 +1667,21 @@ fn ensure_managed_provider_live_after_attach(
|
|
|
1674
1667
|
)))
|
|
1675
1668
|
}
|
|
1676
1669
|
|
|
1677
|
-
///
|
|
1678
|
-
///
|
|
1679
|
-
///
|
|
1680
|
-
/// remains at its inert tail. This function distinguishes
|
|
1681
|
-
/// `provider_alive` from `provider_exited` by:
|
|
1682
|
-
/// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
|
|
1683
|
-
/// 2. If the current command matches the expected provider binary (or
|
|
1684
|
-
/// one of its known aliases), report `Alive`.
|
|
1685
|
-
/// 3. If the current command is a shell tail process AND the pane
|
|
1686
|
-
/// content contains the exit marker `[team-agent] <provider> exited`
|
|
1687
|
-
/// (emitted by `leader_shell_wrapper_command`), report
|
|
1688
|
-
/// `ProviderExited`.
|
|
1689
|
-
/// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
|
|
1690
|
-
/// conservative default — avoid false-positive exit alarms.
|
|
1691
|
-
///
|
|
1692
|
-
/// Note: when the leader shell wrapper is used (CR C-2), a provider exit
|
|
1693
|
-
/// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
|
|
1694
|
-
/// scrollback. Pre-wrapper code that hit `exec claude` would have left the
|
|
1695
|
-
/// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
|
|
1696
|
-
/// failure mode requires this richer health check to surface
|
|
1697
|
-
/// `leader_provider_exited` as a distinct status.
|
|
1670
|
+
/// Reconcile real pane liveness with the supervising wrapper's exit receipt.
|
|
1671
|
+
/// Restored provider output may contain any exit-marker text; it is not process
|
|
1672
|
+
/// state and must never authorize destructive managed-window cleanup.
|
|
1698
1673
|
pub fn leader_provider_health(
|
|
1699
1674
|
transport: &dyn Transport,
|
|
1700
1675
|
pane_id: &PaneId,
|
|
1701
|
-
|
|
1676
|
+
_expected_provider_label: &str,
|
|
1702
1677
|
) -> LeaderProviderHealth {
|
|
1703
|
-
|
|
1704
|
-
let liveness = transport.liveness(pane_id).ok();
|
|
1705
|
-
if matches!(liveness, Some(PaneLiveness::Dead)) {
|
|
1678
|
+
if matches!(transport.liveness(pane_id), Ok(PaneLiveness::Dead)) {
|
|
1706
1679
|
return LeaderProviderHealth::Unreachable;
|
|
1707
1680
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
.query(&target, PaneField::PaneCurrentCommand)
|
|
1711
|
-
.ok()
|
|
1712
|
-
.flatten()
|
|
1713
|
-
.map(|s| s.trim().to_lowercase())
|
|
1714
|
-
.unwrap_or_default();
|
|
1715
|
-
if !current_command.is_empty()
|
|
1716
|
-
&& (current_command == expected_provider_label
|
|
1717
|
-
|| current_command.contains(expected_provider_label))
|
|
1718
|
-
{
|
|
1719
|
-
return LeaderProviderHealth::Alive;
|
|
1720
|
-
}
|
|
1721
|
-
// Current command is NOT the provider — likely fell back to shell.
|
|
1722
|
-
let is_shell = is_interactive_shell_basename(¤t_command);
|
|
1723
|
-
if is_shell {
|
|
1724
|
-
// CR R6: marker text from single-source `leader_provider_exit_marker`
|
|
1725
|
-
// so the wrapper printf and the health-check substring cannot drift.
|
|
1726
|
-
let exit_marker_substr =
|
|
1727
|
-
crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
|
|
1728
|
-
if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
|
|
1729
|
-
if cap.text.contains(&exit_marker_substr) {
|
|
1730
|
-
return LeaderProviderHealth::ProviderExited;
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1681
|
+
if matches!(transport.provider_exit_status(pane_id), Ok(Some(_))) {
|
|
1682
|
+
return LeaderProviderHealth::ProviderExited;
|
|
1733
1683
|
}
|
|
1734
|
-
//
|
|
1735
|
-
// confirm provider exit. Treat as Alive.
|
|
1684
|
+
// Missing/unsupported receipts (including old wrappers) are not proof of exit.
|
|
1736
1685
|
LeaderProviderHealth::Alive
|
|
1737
1686
|
}
|
|
1738
1687
|
|
|
@@ -1750,7 +1699,6 @@ pub enum LeaderProviderHealth {
|
|
|
1750
1699
|
|
|
1751
1700
|
/// 0.4.x (CR R6 + R3): single-source shell-tail detection.
|
|
1752
1701
|
/// Used by:
|
|
1753
|
-
/// - `leader_provider_health` to decide "pane is at the shell tail"
|
|
1754
1702
|
/// - shutdown logic to recognise a leader pane in shell-tail mode
|
|
1755
1703
|
/// as still owned by the leader (not stray).
|
|
1756
1704
|
///
|
|
@@ -1859,6 +1807,17 @@ fn persist_managed_leader_binding(
|
|
|
1859
1807
|
&serde_json::json!(socket),
|
|
1860
1808
|
);
|
|
1861
1809
|
let bound_panes = merge_bound_panes(existing_recv.as_ref(), new_binding);
|
|
1810
|
+
let existing_owner = state
|
|
1811
|
+
.get("teams")
|
|
1812
|
+
.and_then(|teams| teams.get(&team_key))
|
|
1813
|
+
.and_then(|team| team.get("team_owner"))
|
|
1814
|
+
.cloned();
|
|
1815
|
+
let keep_field = |existing: Option<&serde_json::Value>, key: &str, fallback: serde_json::Value| {
|
|
1816
|
+
existing
|
|
1817
|
+
.and_then(|value| value.get(key))
|
|
1818
|
+
.cloned()
|
|
1819
|
+
.unwrap_or(fallback)
|
|
1820
|
+
};
|
|
1862
1821
|
let (scalar_pane, scalar_session, scalar_window, scalar_attached_at) =
|
|
1863
1822
|
match existing_recv.as_ref() {
|
|
1864
1823
|
Some(recv) if keep_existing_scalar => (
|
|
@@ -1867,20 +1826,41 @@ fn persist_managed_leader_binding(
|
|
|
1867
1826
|
.unwrap_or(pane.as_str())
|
|
1868
1827
|
.to_string(),
|
|
1869
1828
|
recv.get("session_name")
|
|
1870
|
-
.
|
|
1871
|
-
.
|
|
1872
|
-
.to_string(),
|
|
1829
|
+
.cloned()
|
|
1830
|
+
.unwrap_or_else(|| serde_json::json!(session)),
|
|
1873
1831
|
recv.get("window_name")
|
|
1874
|
-
.
|
|
1875
|
-
.
|
|
1876
|
-
.to_string(),
|
|
1832
|
+
.cloned()
|
|
1833
|
+
.unwrap_or_else(|| serde_json::json!(window)),
|
|
1877
1834
|
recv.get("attached_at")
|
|
1878
1835
|
.and_then(serde_json::Value::as_str)
|
|
1879
1836
|
.unwrap_or(now.as_str())
|
|
1880
1837
|
.to_string(),
|
|
1881
1838
|
),
|
|
1882
|
-
_ => (
|
|
1839
|
+
_ => (
|
|
1840
|
+
pane.clone(),
|
|
1841
|
+
serde_json::json!(session),
|
|
1842
|
+
serde_json::json!(window),
|
|
1843
|
+
now.clone(),
|
|
1844
|
+
),
|
|
1883
1845
|
};
|
|
1846
|
+
let leader_session_uuid = if keep_existing_scalar {
|
|
1847
|
+
keep_field(
|
|
1848
|
+
existing_recv.as_ref(),
|
|
1849
|
+
"leader_session_uuid",
|
|
1850
|
+
serde_json::json!(identity.leader_session_uuid),
|
|
1851
|
+
)
|
|
1852
|
+
} else {
|
|
1853
|
+
serde_json::json!(identity.leader_session_uuid)
|
|
1854
|
+
};
|
|
1855
|
+
let discovery = if keep_existing_scalar {
|
|
1856
|
+
keep_field(
|
|
1857
|
+
existing_recv.as_ref(),
|
|
1858
|
+
"discovery",
|
|
1859
|
+
serde_json::json!("managed_launcher"),
|
|
1860
|
+
)
|
|
1861
|
+
} else {
|
|
1862
|
+
serde_json::json!("managed_launcher")
|
|
1863
|
+
};
|
|
1884
1864
|
let receiver = serde_json::json!({
|
|
1885
1865
|
"mode": "direct_tmux",
|
|
1886
1866
|
"status": "attached",
|
|
@@ -1890,20 +1870,43 @@ fn persist_managed_leader_binding(
|
|
|
1890
1870
|
"session_name": scalar_session,
|
|
1891
1871
|
"window_name": scalar_window,
|
|
1892
1872
|
"tmux_socket": socket,
|
|
1893
|
-
"leader_session_uuid":
|
|
1873
|
+
"leader_session_uuid": leader_session_uuid.clone(),
|
|
1894
1874
|
"owner_epoch": owner_epoch,
|
|
1895
1875
|
"attached_at": scalar_attached_at,
|
|
1896
|
-
"discovery":
|
|
1876
|
+
"discovery": discovery,
|
|
1897
1877
|
"bound_panes": bound_panes,
|
|
1898
1878
|
});
|
|
1879
|
+
let owner_uuid = if keep_existing_scalar {
|
|
1880
|
+
keep_field(
|
|
1881
|
+
existing_owner.as_ref(),
|
|
1882
|
+
"leader_session_uuid",
|
|
1883
|
+
leader_session_uuid.clone(),
|
|
1884
|
+
)
|
|
1885
|
+
} else {
|
|
1886
|
+
leader_session_uuid.clone()
|
|
1887
|
+
};
|
|
1888
|
+
let claimed_via = if keep_existing_scalar {
|
|
1889
|
+
keep_field(
|
|
1890
|
+
existing_owner.as_ref(),
|
|
1891
|
+
"claimed_via",
|
|
1892
|
+
serde_json::json!("claim-leader"),
|
|
1893
|
+
)
|
|
1894
|
+
} else {
|
|
1895
|
+
serde_json::json!("claim-leader")
|
|
1896
|
+
};
|
|
1897
|
+
let claimed_at = if keep_existing_scalar {
|
|
1898
|
+
keep_field(existing_owner.as_ref(), "claimed_at", serde_json::json!(now))
|
|
1899
|
+
} else {
|
|
1900
|
+
serde_json::json!(now)
|
|
1901
|
+
};
|
|
1899
1902
|
let owner = serde_json::json!({
|
|
1900
1903
|
"pane_id": scalar_pane,
|
|
1901
1904
|
"provider": provider.clone(),
|
|
1902
1905
|
"machine_fingerprint": identity.machine_fingerprint,
|
|
1903
|
-
"leader_session_uuid":
|
|
1906
|
+
"leader_session_uuid": owner_uuid,
|
|
1904
1907
|
"owner_epoch": owner_epoch,
|
|
1905
|
-
"claimed_at":
|
|
1906
|
-
"claimed_via":
|
|
1908
|
+
"claimed_at": claimed_at,
|
|
1909
|
+
"claimed_via": claimed_via,
|
|
1907
1910
|
"os_user": identity.os_user,
|
|
1908
1911
|
});
|
|
1909
1912
|
if let Some(obj) = state.as_object_mut() {
|
|
@@ -2946,6 +2949,7 @@ mod tests {
|
|
|
2946
2949
|
screens: Mutex<Vec<String>>,
|
|
2947
2950
|
sent: Mutex<Vec<(Target, Vec<Key>)>>,
|
|
2948
2951
|
liveness: PaneLiveness,
|
|
2952
|
+
exit_status: Option<u8>,
|
|
2949
2953
|
targets: Vec<PaneInfo>,
|
|
2950
2954
|
window_options: Mutex<Vec<(String, String)>>,
|
|
2951
2955
|
window_option_error: Mutex<Option<String>>,
|
|
@@ -2959,6 +2963,7 @@ mod tests {
|
|
|
2959
2963
|
screens: Mutex::new(screens),
|
|
2960
2964
|
sent: Mutex::new(Vec::new()),
|
|
2961
2965
|
liveness: PaneLiveness::Unknown,
|
|
2966
|
+
exit_status: None,
|
|
2962
2967
|
targets: Vec::new(),
|
|
2963
2968
|
window_options: Mutex::new(Vec::new()),
|
|
2964
2969
|
window_option_error: Mutex::new(None),
|
|
@@ -2972,6 +2977,7 @@ mod tests {
|
|
|
2972
2977
|
screens: Mutex::new(Vec::new()),
|
|
2973
2978
|
sent: Mutex::new(Vec::new()),
|
|
2974
2979
|
liveness,
|
|
2980
|
+
exit_status: None,
|
|
2975
2981
|
targets: Vec::new(),
|
|
2976
2982
|
window_options: Mutex::new(Vec::new()),
|
|
2977
2983
|
window_option_error: Mutex::new(None),
|
|
@@ -2985,6 +2991,7 @@ mod tests {
|
|
|
2985
2991
|
screens: Mutex::new(Vec::new()),
|
|
2986
2992
|
sent: Mutex::new(Vec::new()),
|
|
2987
2993
|
liveness,
|
|
2994
|
+
exit_status: None,
|
|
2988
2995
|
targets,
|
|
2989
2996
|
window_options: Mutex::new(Vec::new()),
|
|
2990
2997
|
window_option_error: Mutex::new(None),
|
|
@@ -3000,6 +3007,7 @@ mod tests {
|
|
|
3000
3007
|
screens: Mutex::new(Vec::new()),
|
|
3001
3008
|
sent: Mutex::new(Vec::new()),
|
|
3002
3009
|
liveness: PaneLiveness::Unknown,
|
|
3010
|
+
exit_status: None,
|
|
3003
3011
|
targets: Vec::new(),
|
|
3004
3012
|
window_options: Mutex::new(Vec::new()),
|
|
3005
3013
|
window_option_error: Mutex::new(Some(error.to_string())),
|
|
@@ -3131,6 +3139,10 @@ mod tests {
|
|
|
3131
3139
|
Ok(self.liveness)
|
|
3132
3140
|
}
|
|
3133
3141
|
|
|
3142
|
+
fn provider_exit_status(&self, _pane: &PaneId) -> Result<Option<u8>, TransportError> {
|
|
3143
|
+
Ok(self.exit_status)
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3134
3146
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
3135
3147
|
Ok(self.targets.clone())
|
|
3136
3148
|
}
|
|
@@ -3804,22 +3816,37 @@ mod tests {
|
|
|
3804
3816
|
}
|
|
3805
3817
|
|
|
3806
3818
|
#[test]
|
|
3807
|
-
fn
|
|
3819
|
+
fn managed_attach_teardown_requires_exit_receipt_not_replayed_markers() {
|
|
3808
3820
|
let spawned = managed_spawn_result();
|
|
3809
|
-
let transport = ScriptedTransport::new(vec![
|
|
3810
|
-
"[team-agent]
|
|
3821
|
+
let mut transport = ScriptedTransport::new(vec![
|
|
3822
|
+
"[team-agent] pi exited with 1\n[team-agent] Provider exited".to_string(),
|
|
3811
3823
|
]);
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3824
|
+
transport.liveness = PaneLiveness::Live;
|
|
3825
|
+
for exit_status in [None, Some(0), Some(17)] {
|
|
3826
|
+
transport.exit_status = exit_status;
|
|
3827
|
+
let watch = super::ManagedAttachWatch {
|
|
3828
|
+
transport: &transport,
|
|
3829
|
+
spawned: &spawned,
|
|
3830
|
+
session: &spawned.session,
|
|
3831
|
+
session_existed_before: false,
|
|
3832
|
+
workspace: Path::new("/tmp"),
|
|
3833
|
+
provider_label: "pi",
|
|
3834
|
+
};
|
|
3835
|
+
assert_eq!(
|
|
3836
|
+
super::managed_attach_should_teardown(&watch),
|
|
3837
|
+
exit_status.is_some()
|
|
3838
|
+
);
|
|
3839
|
+
}
|
|
3840
|
+
assert_eq!(
|
|
3841
|
+
transport.screens.lock().unwrap().len(),
|
|
3842
|
+
1,
|
|
3843
|
+
"health must not read history"
|
|
3844
|
+
);
|
|
3845
|
+
transport.liveness = PaneLiveness::Dead;
|
|
3846
|
+
transport.exit_status = None;
|
|
3847
|
+
assert_eq!(
|
|
3848
|
+
super::leader_provider_health(&transport, &spawned.pane_id, "pi"),
|
|
3849
|
+
super::LeaderProviderHealth::Unreachable
|
|
3823
3850
|
);
|
|
3824
3851
|
}
|
|
3825
3852
|
|
|
@@ -3881,18 +3908,22 @@ mod tests {
|
|
|
3881
3908
|
}
|
|
3882
3909
|
|
|
3883
3910
|
fn persist_test_identity(workspace: &Path) -> LeaderIdentity {
|
|
3911
|
+
persist_test_identity_named(workspace, "tester")
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
fn persist_test_identity_named(workspace: &Path, os_user: &str) -> LeaderIdentity {
|
|
3884
3915
|
LeaderIdentity {
|
|
3885
3916
|
leader_session_uuid: LeaderSessionUuid::derive(
|
|
3886
3917
|
"fp",
|
|
3887
3918
|
&workspace.to_string_lossy(),
|
|
3888
|
-
|
|
3919
|
+
os_user,
|
|
3889
3920
|
"current",
|
|
3890
3921
|
)
|
|
3891
3922
|
.unwrap(),
|
|
3892
3923
|
leader_session_uuid_source: LeaderSessionUuidSource::Derived,
|
|
3893
3924
|
machine_fingerprint: "fp".to_string(),
|
|
3894
3925
|
workspace_abspath: workspace.to_path_buf(),
|
|
3895
|
-
os_user:
|
|
3926
|
+
os_user: os_user.to_string(),
|
|
3896
3927
|
team_id: TeamKey::new("current"),
|
|
3897
3928
|
}
|
|
3898
3929
|
}
|
|
@@ -3995,6 +4026,84 @@ mod tests {
|
|
|
3995
4026
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
3996
4027
|
}
|
|
3997
4028
|
|
|
4029
|
+
#[test]
|
|
4030
|
+
fn multi_leader_persist_keeps_first_owner_uuid() {
|
|
4031
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
4032
|
+
"ta-multi-leader-uuid-{}",
|
|
4033
|
+
std::process::id()
|
|
4034
|
+
));
|
|
4035
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
4036
|
+
let original_uuid = "b2b230cfd645e78cf49e8147ba32ff3b";
|
|
4037
|
+
let state = serde_json::json!({
|
|
4038
|
+
"active_team_key": "current",
|
|
4039
|
+
"teams": {
|
|
4040
|
+
"current": {
|
|
4041
|
+
"leader_receiver": {
|
|
4042
|
+
"status": "attached",
|
|
4043
|
+
"pane_id": "%0",
|
|
4044
|
+
"discovery": "env_pane",
|
|
4045
|
+
"leader_session_uuid": original_uuid,
|
|
4046
|
+
"owner_epoch": 1,
|
|
4047
|
+
"attached_at": "2026-09-09T17:08:31.965311+00:00",
|
|
4048
|
+
"tmux_socket": "/private/tmp/tmux-501/ta-33bddc6e7687",
|
|
4049
|
+
"session_name": null,
|
|
4050
|
+
"provider": "codex"
|
|
4051
|
+
},
|
|
4052
|
+
"team_owner": {
|
|
4053
|
+
"pane_id": "%0",
|
|
4054
|
+
"leader_session_uuid": original_uuid,
|
|
4055
|
+
"owner_epoch": 1,
|
|
4056
|
+
"claimed_via": "quick-start"
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
});
|
|
4061
|
+
std::fs::write(
|
|
4062
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
4063
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
4064
|
+
)
|
|
4065
|
+
.unwrap();
|
|
4066
|
+
let mut plan = persist_test_plan(&workspace);
|
|
4067
|
+
plan.identity = Some(persist_test_identity_named(&workspace, "second-launcher"));
|
|
4068
|
+
let second_uuid = plan
|
|
4069
|
+
.identity
|
|
4070
|
+
.as_ref()
|
|
4071
|
+
.unwrap()
|
|
4072
|
+
.leader_session_uuid
|
|
4073
|
+
.as_str()
|
|
4074
|
+
.to_string();
|
|
4075
|
+
assert_ne!(second_uuid, original_uuid);
|
|
4076
|
+
let spawned = SpawnResult {
|
|
4077
|
+
pane_id: PaneId::new("%2"),
|
|
4078
|
+
session: SessionName::new(
|
|
4079
|
+
"team-agent-leader-codex-team-agent-live-0578-r4-19450903",
|
|
4080
|
+
),
|
|
4081
|
+
window: WindowName::new("codex-second"),
|
|
4082
|
+
child_pid: Some(2),
|
|
4083
|
+
};
|
|
4084
|
+
persist_managed_leader_binding(&plan, &workspace, &spawned).expect("second persist");
|
|
4085
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4086
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
4087
|
+
let owner = &loaded["teams"]["current"]["team_owner"];
|
|
4088
|
+
assert_eq!(recv["pane_id"], serde_json::json!("%0"));
|
|
4089
|
+
assert_eq!(recv["leader_session_uuid"], serde_json::json!(original_uuid));
|
|
4090
|
+
assert_eq!(recv["discovery"], serde_json::json!("env_pane"));
|
|
4091
|
+
assert_eq!(recv["session_name"], serde_json::Value::Null);
|
|
4092
|
+
assert_eq!(recv["owner_epoch"], serde_json::json!(1));
|
|
4093
|
+
assert_eq!(
|
|
4094
|
+
owner["leader_session_uuid"],
|
|
4095
|
+
serde_json::json!(original_uuid)
|
|
4096
|
+
);
|
|
4097
|
+
let panes = recv["bound_panes"].as_array().expect("bound_panes");
|
|
4098
|
+
let ids: Vec<&str> = panes
|
|
4099
|
+
.iter()
|
|
4100
|
+
.filter_map(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str))
|
|
4101
|
+
.collect();
|
|
4102
|
+
assert!(ids.contains(&"%0"), "bound_panes missing first: {recv}");
|
|
4103
|
+
assert!(ids.contains(&"%2"), "bound_panes missing second: {recv}");
|
|
4104
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
3998
4107
|
#[test]
|
|
3999
4108
|
fn multi_leader_clear_promotes_remaining() {
|
|
4000
4109
|
let workspace =
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
use super::*;
|
|
2
2
|
|
|
3
|
+
#[path = "../../../tests/support/hermetic.rs"]
|
|
4
|
+
mod hermetic;
|
|
5
|
+
|
|
3
6
|
// =====================================================================
|
|
4
7
|
// 5. leader_session_name — sha1 派生 + 文件夹消毒(unimplemented → RED)
|
|
5
8
|
// =====================================================================
|
|
@@ -111,18 +114,21 @@ fn owner_bind_refused_event_name_is_owner_bind_refused() {
|
|
|
111
114
|
// old uuid 为 None → old_uuid_prefix == ""(空串,非缺省);全 32 hex uuid 绝不出现在任何字段。
|
|
112
115
|
// unimplemented → RED。
|
|
113
116
|
#[test]
|
|
117
|
+
#[serial_test::serial(env)]
|
|
114
118
|
fn emit_owner_bound_event_logs_prefix_only_never_full_uuid() {
|
|
115
119
|
let _lock = ENV_LOCK.lock().unwrap();
|
|
116
|
-
let
|
|
117
|
-
|
|
118
|
-
let
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
("TEAM_AGENT_LEADER_PROVIDER", Some("codex")),
|
|
122
|
-
("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
|
|
123
|
-
]);
|
|
120
|
+
let hermetic = hermetic::HermeticTestEnv::enter("owner-bound-uuid");
|
|
121
|
+
let ws = hermetic.workspace("emit");
|
|
122
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%7");
|
|
123
|
+
let _fingerprint = hermetic.with_env("TEAM_AGENT_MACHINE_FINGERPRINT", "fp");
|
|
124
|
+
let _provider = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "codex");
|
|
124
125
|
let result = bind_owner_from_caller_pane(&ws, &TeamKey::new("default"), None).unwrap();
|
|
125
|
-
assert!(
|
|
126
|
+
assert!(
|
|
127
|
+
result.ok,
|
|
128
|
+
"caller pane must produce an owner binding; reason={:?} hint={:?}",
|
|
129
|
+
result.reason,
|
|
130
|
+
result.hint
|
|
131
|
+
);
|
|
126
132
|
let owner = result.owner.as_ref().expect("successful bind has owner");
|
|
127
133
|
let full = owner
|
|
128
134
|
.leader_session_uuid
|