@team-agent/installer 0.4.8 → 0.4.10
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/diagnose.rs +52 -0
- package/crates/team-agent/src/cli/mod.rs +10 -0
- package/crates/team-agent/src/cli/status_port.rs +4 -1
- package/crates/team-agent/src/compiler.rs +78 -9
- package/crates/team-agent/src/coordinator/tick.rs +96 -0
- package/crates/team-agent/src/leader/start.rs +180 -6
- package/crates/team-agent/src/lifecycle/launch.rs +155 -3
- package/crates/team-agent/src/lifecycle/profile_launch.rs +20 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +17 -1
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/types.rs +86 -0
- package/crates/team-agent/src/model/enums.rs +65 -0
- package/crates/team-agent/src/model/spec.rs +34 -2
- package/crates/team-agent/src/os_probe.rs +79 -0
- package/crates/team-agent/src/provider/adapter.rs +11 -3
- package/crates/team-agent/src/provider/adapters/claude.rs +8 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +10 -0
- package/crates/team-agent/src/provider/types.rs +8 -0
- package/crates/team-agent/src/tmux_backend.rs +134 -1
- package/crates/team-agent/src/transport/test_support.rs +62 -3
- package/crates/team-agent/src/transport.rs +38 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -33,6 +33,39 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
|
|
|
33
33
|
"issue": "leader_not_attached",
|
|
34
34
|
"action": "attach or claim a leader receiver before sending work",
|
|
35
35
|
}));
|
|
36
|
+
} else {
|
|
37
|
+
// 0.4.x (CR R2 P0): leader provider health reconciliation. The
|
|
38
|
+
// leader_receiver may be marked `attached` (pane addressable) but the
|
|
39
|
+
// provider process has exited — pane fell back to shell with the exit
|
|
40
|
+
// marker. Distinguish `leader_provider_exited` from `attached` so
|
|
41
|
+
// status/diagnose surfaces the real state.
|
|
42
|
+
if let Some((pane_id, provider_label)) = leader_pane_and_provider(state) {
|
|
43
|
+
let health = crate::leader::leader_provider_health(
|
|
44
|
+
backend,
|
|
45
|
+
&crate::transport::PaneId::new(pane_id),
|
|
46
|
+
&provider_label,
|
|
47
|
+
);
|
|
48
|
+
match health {
|
|
49
|
+
crate::leader::LeaderProviderHealth::ProviderExited => {
|
|
50
|
+
issues.push(json!("leader_provider_exited"));
|
|
51
|
+
repairs.push(json!({
|
|
52
|
+
"issue": "leader_provider_exited",
|
|
53
|
+
"action": format!(
|
|
54
|
+
"leader pane fell back to shell — provider `{provider_label}` exited; \
|
|
55
|
+
relaunch with `team-agent {provider_label}` to restart the provider"
|
|
56
|
+
),
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
crate::leader::LeaderProviderHealth::Unreachable => {
|
|
60
|
+
issues.push(json!("leader_provider_unreachable"));
|
|
61
|
+
repairs.push(json!({
|
|
62
|
+
"issue": "leader_provider_unreachable",
|
|
63
|
+
"action": "leader pane is dead — relaunch the leader",
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
crate::leader::LeaderProviderHealth::Alive => {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
if let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) {
|
|
@@ -62,6 +95,25 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
|
|
|
62
95
|
(Value::Array(issues), Value::Array(repairs))
|
|
63
96
|
}
|
|
64
97
|
|
|
98
|
+
/// 0.4.x (CR R2): pull (pane_id, provider_label) from leader_receiver. Used
|
|
99
|
+
/// by `leader_provider_health` reconcile in diagnose. Returns None when the
|
|
100
|
+
/// leader is not attached or any field is missing.
|
|
101
|
+
fn leader_pane_and_provider(state: &Value) -> Option<(String, String)> {
|
|
102
|
+
let receiver = state.get("leader_receiver")?;
|
|
103
|
+
let pane_id = receiver
|
|
104
|
+
.get("pane_id")
|
|
105
|
+
.and_then(Value::as_str)
|
|
106
|
+
.filter(|s| !s.is_empty())?
|
|
107
|
+
.to_string();
|
|
108
|
+
let provider = receiver
|
|
109
|
+
.get("provider")
|
|
110
|
+
.and_then(Value::as_str)
|
|
111
|
+
.filter(|s| !s.is_empty())
|
|
112
|
+
.unwrap_or("claude")
|
|
113
|
+
.to_string();
|
|
114
|
+
Some((pane_id, provider))
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
fn leader_receiver_attached(state: &Value) -> bool {
|
|
66
118
|
let Some(receiver) = state.get("leader_receiver") else {
|
|
67
119
|
return false;
|
|
@@ -1685,6 +1685,16 @@ pub mod lifecycle_port {
|
|
|
1685
1685
|
/// matching cannot reap the leader — including when ANOTHER team's bare shutdown
|
|
1686
1686
|
/// runs, where the leader is never in the invoker's ancestry.
|
|
1687
1687
|
///
|
|
1688
|
+
/// 0.4.x (CR R3): leader shell wrapper interaction. The leader pane's
|
|
1689
|
+
/// controlling process is the `sh -lc "...; exec ${SHELL} -l"` that
|
|
1690
|
+
/// runs Claude as a CHILD. When Claude exits and the shell wrapper falls
|
|
1691
|
+
/// back via `exec ${SHELL} -l`, the controlling PID is REPLACED in-place
|
|
1692
|
+
/// by the interactive shell (same pane_pid). Because this function
|
|
1693
|
+
/// protects by `pane.pane_pid` (Source 1 & 2), the fallback interactive
|
|
1694
|
+
/// shell is already covered by the same protection set — shutdown will
|
|
1695
|
+
/// NOT treat the fallback shell as a stray process. Verified by the
|
|
1696
|
+
/// `leader_fallback_shell_protected_when_provider_exited` test.
|
|
1697
|
+
///
|
|
1688
1698
|
/// Two leader-pane sources(N39 双来源,真机 grounded):
|
|
1689
1699
|
/// 1. **Session prefix**: tmux session starts with `team-agent-leader-`(契约 grounded;
|
|
1690
1700
|
/// 覆盖 LeaderStartMode::NewTmuxSession / AttachExisting).
|
|
@@ -743,7 +743,10 @@ use rusqlite::params;
|
|
|
743
743
|
return json!({});
|
|
744
744
|
};
|
|
745
745
|
let mut out = Map::new();
|
|
746
|
-
|
|
746
|
+
// 0.4.x Phase 1: add `worker_state` (canonical 5-state product
|
|
747
|
+
// surface). `activity` is preserved alongside as the deprecated
|
|
748
|
+
// legacy classifier output (CR R3 same-source contract).
|
|
749
|
+
for key in ["status", "provider", "worker_state", "activity", "last_output_at"] {
|
|
747
750
|
if let Some(value) = input.get(key) {
|
|
748
751
|
out.insert(key.to_string(), value.clone());
|
|
749
752
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
use std::fs;
|
|
27
27
|
use std::path::Path;
|
|
28
28
|
|
|
29
|
+
use crate::model::enums::{Provider, ProviderEffort};
|
|
29
30
|
use crate::model::yaml::Value;
|
|
30
31
|
use crate::model::{paths, spec, yaml, ModelError};
|
|
31
32
|
|
|
@@ -182,17 +183,35 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
182
183
|
})
|
|
183
184
|
.collect::<Vec<_>>();
|
|
184
185
|
|
|
186
|
+
// 0.4.x provider effort MVP step 2: validate TEAM.md provider_effort early
|
|
187
|
+
// (unknown literal rejects compile). Empty/absent → no team-level effort.
|
|
188
|
+
let team_provider_effort = match string_field(&team_meta, "provider_effort") {
|
|
189
|
+
Some(raw) if !raw.trim().is_empty() => {
|
|
190
|
+
let value = raw.trim();
|
|
191
|
+
let parsed = ProviderEffort::parse(value).ok_or_else(|| {
|
|
192
|
+
ModelError::Validation(format!(
|
|
193
|
+
"{}: unknown provider_effort '{value}' (allowed: low|medium|high|xhigh|max)",
|
|
194
|
+
team_md.display()
|
|
195
|
+
))
|
|
196
|
+
})?;
|
|
197
|
+
Some(parsed)
|
|
198
|
+
}
|
|
199
|
+
_ => None,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let mut team_fields: Vec<(&str, Value)> = vec![
|
|
203
|
+
("name", Value::Str(team_name.clone())),
|
|
204
|
+
("mode", Value::Str("supervisor_worker".to_string())),
|
|
205
|
+
("objective", Value::Str(objective)),
|
|
206
|
+
("workspace", Value::Str(workspace_s)),
|
|
207
|
+
];
|
|
208
|
+
if let Some(effort) = team_provider_effort {
|
|
209
|
+
team_fields.push(("provider_effort", Value::Str(effort.as_str().to_string())));
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
let spec = map(vec![
|
|
186
213
|
("version", Value::Int(1)),
|
|
187
|
-
(
|
|
188
|
-
"team",
|
|
189
|
-
map(vec![
|
|
190
|
-
("name", Value::Str(team_name.clone())),
|
|
191
|
-
("mode", Value::Str("supervisor_worker".to_string())),
|
|
192
|
-
("objective", Value::Str(objective)),
|
|
193
|
-
("workspace", Value::Str(workspace_s)),
|
|
194
|
-
]),
|
|
195
|
-
),
|
|
214
|
+
("team", map(team_fields)),
|
|
196
215
|
(
|
|
197
216
|
"leader",
|
|
198
217
|
map(vec![
|
|
@@ -366,6 +385,56 @@ pub fn compile_role_agent(
|
|
|
366
385
|
if let Some(profile) = string_field(&meta, "profile") {
|
|
367
386
|
agent_items.push(("profile", Value::Str(profile)));
|
|
368
387
|
}
|
|
388
|
+
// 0.4.x provider effort MVP step 3: resolve effort with role > team > none.
|
|
389
|
+
// Validate (unknown literal) AND check provider/effort compatibility
|
|
390
|
+
// (max is Claude-only; emit hard error for max + non-Claude here so
|
|
391
|
+
// unsupported combinations fail at compile, not at runtime).
|
|
392
|
+
let role_effort = match string_field(&meta, "effort") {
|
|
393
|
+
Some(raw) if !raw.trim().is_empty() => {
|
|
394
|
+
let value = raw.trim();
|
|
395
|
+
let parsed = ProviderEffort::parse(value).ok_or_else(|| {
|
|
396
|
+
ModelError::Validation(format!(
|
|
397
|
+
"{}: unknown effort '{value}' (allowed: low|medium|high|xhigh|max)",
|
|
398
|
+
role_path.display()
|
|
399
|
+
))
|
|
400
|
+
})?;
|
|
401
|
+
Some(parsed)
|
|
402
|
+
}
|
|
403
|
+
_ => None,
|
|
404
|
+
};
|
|
405
|
+
let team_effort = match string_field(team_meta, "provider_effort") {
|
|
406
|
+
Some(raw) if !raw.trim().is_empty() => ProviderEffort::parse(raw.trim()),
|
|
407
|
+
_ => None,
|
|
408
|
+
};
|
|
409
|
+
let resolved_effort = role_effort.or(team_effort);
|
|
410
|
+
if let Some(effort) = resolved_effort {
|
|
411
|
+
// Reject max + non-Claude at compile time.
|
|
412
|
+
let provider_str = agent_items
|
|
413
|
+
.iter()
|
|
414
|
+
.find(|(k, _)| *k == "provider")
|
|
415
|
+
.and_then(|(_, v)| match v {
|
|
416
|
+
Value::Str(s) => Some(s.as_str()),
|
|
417
|
+
_ => None,
|
|
418
|
+
})
|
|
419
|
+
.unwrap_or("");
|
|
420
|
+
let provider_enum = match provider_str {
|
|
421
|
+
"claude" => Provider::Claude,
|
|
422
|
+
"claude_code" => Provider::ClaudeCode,
|
|
423
|
+
"codex" => Provider::Codex,
|
|
424
|
+
"copilot" => Provider::Copilot,
|
|
425
|
+
"gemini_cli" => Provider::GeminiCli,
|
|
426
|
+
"fake" => Provider::Fake,
|
|
427
|
+
_ => Provider::Codex, // unknown providers caught elsewhere
|
|
428
|
+
};
|
|
429
|
+
if effort.is_claude_only() && !matches!(provider_enum, Provider::Claude | Provider::ClaudeCode) {
|
|
430
|
+
return Err(ModelError::Validation(format!(
|
|
431
|
+
"{}: effort '{}' is only supported by claude/claude_code (provider: {provider_str})",
|
|
432
|
+
role_path.display(),
|
|
433
|
+
effort.as_str()
|
|
434
|
+
)));
|
|
435
|
+
}
|
|
436
|
+
agent_items.push(("effort", Value::Str(effort.as_str().to_string())));
|
|
437
|
+
}
|
|
369
438
|
Ok(CompiledRole {
|
|
370
439
|
id,
|
|
371
440
|
role,
|
|
@@ -3103,6 +3103,11 @@ fn write_activity(
|
|
|
3103
3103
|
.get("last_output_at")
|
|
3104
3104
|
.and_then(Value::as_str)
|
|
3105
3105
|
.map(str::to_string);
|
|
3106
|
+
// 0.4.x Phase 1: resolve the 5-state worker runtime state alongside the
|
|
3107
|
+
// legacy `activity` write. CR R3: activity field is preserved as the
|
|
3108
|
+
// deprecated surface; worker_state is the new canonical surface.
|
|
3109
|
+
let worker_state =
|
|
3110
|
+
resolve_worker_runtime_state_with_fg_pgrp(agent, Some(activity));
|
|
3106
3111
|
let Some(agent_obj) = agent.as_object_mut() else {
|
|
3107
3112
|
return previous_last_output;
|
|
3108
3113
|
};
|
|
@@ -3115,6 +3120,10 @@ fn write_activity(
|
|
|
3115
3120
|
"rationale": activity.rationale,
|
|
3116
3121
|
}),
|
|
3117
3122
|
);
|
|
3123
|
+
agent_obj.insert(
|
|
3124
|
+
"worker_state".to_string(),
|
|
3125
|
+
serde_json::json!(worker_state.as_wire()),
|
|
3126
|
+
);
|
|
3118
3127
|
if output_advanced {
|
|
3119
3128
|
let last_output_at = chrono::Utc::now().to_rfc3339();
|
|
3120
3129
|
agent_obj.insert(
|
|
@@ -3126,6 +3135,83 @@ fn write_activity(
|
|
|
3126
3135
|
previous_last_output
|
|
3127
3136
|
}
|
|
3128
3137
|
|
|
3138
|
+
/// 0.4.x Phase 1 worker-runtime-state resolver.
|
|
3139
|
+
///
|
|
3140
|
+
/// Pure resolution from (agent JSON, optional activity classifier output).
|
|
3141
|
+
/// Reads `agent.pane_id`, `agent.pane_pid`, `agent.awaiting_human_confirm`,
|
|
3142
|
+
/// and the trust/startup approval flags. Uses
|
|
3143
|
+
/// `os_probe::pane_foreground_and_root_pgrp` to detect a child process
|
|
3144
|
+
/// occupying the terminal foreground.
|
|
3145
|
+
///
|
|
3146
|
+
/// Precedence (matches Phase 1 plan §4):
|
|
3147
|
+
/// 1. Dead — pane_pid exists but `getpgid` returns no process,
|
|
3148
|
+
/// OR foreground probe explicitly reports the PID gone.
|
|
3149
|
+
/// 2. Blocked — agent.awaiting_human_confirm / startup-trust flags.
|
|
3150
|
+
/// 3. Busy — fg_pgrp != root_pgrp (child occupies terminal) OR
|
|
3151
|
+
/// JSONL classifier reports Working.
|
|
3152
|
+
/// 4. ProbablyIdle — JSONL classifier reports Idle AND no fg-pgrp
|
|
3153
|
+
/// conflict (or probe unavailable + activity idle).
|
|
3154
|
+
/// 5. Unknown — missing pane_pid, probe error, or no decisive
|
|
3155
|
+
/// signal. Iron law: never silently Idle.
|
|
3156
|
+
pub(crate) fn resolve_worker_runtime_state_with_fg_pgrp(
|
|
3157
|
+
agent: &Value,
|
|
3158
|
+
activity: Option<&crate::messaging::AgentActivity>,
|
|
3159
|
+
) -> crate::messaging::WorkerRuntimeState {
|
|
3160
|
+
use crate::messaging::{ActivityStatus, WorkerRuntimeState};
|
|
3161
|
+
|
|
3162
|
+
// §4.2 Blocked: trust prompt / awaiting human confirm.
|
|
3163
|
+
let blocked = agent
|
|
3164
|
+
.get("awaiting_human_confirm")
|
|
3165
|
+
.and_then(Value::as_bool)
|
|
3166
|
+
.unwrap_or(false)
|
|
3167
|
+
|| agent
|
|
3168
|
+
.get("approval")
|
|
3169
|
+
.and_then(Value::as_str)
|
|
3170
|
+
.is_some_and(|s| s == "pending" || s == "awaiting_trust_prompt")
|
|
3171
|
+
|| agent
|
|
3172
|
+
.get("approval_status")
|
|
3173
|
+
.and_then(Value::as_str)
|
|
3174
|
+
.is_some_and(|s| s == "pending" || s == "awaiting_trust_prompt");
|
|
3175
|
+
if blocked {
|
|
3176
|
+
return WorkerRuntimeState::Blocked;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// §4.3 Busy via fg-pgrp probe (when pane_pid is available).
|
|
3180
|
+
let pane_pid = agent.get("pane_pid").and_then(Value::as_u64).map(|p| p as u32);
|
|
3181
|
+
if let Some(pid) = pane_pid {
|
|
3182
|
+
match crate::os_probe::pane_foreground_and_root_pgrp(pid) {
|
|
3183
|
+
Ok(Some((tpgid, pgid))) => {
|
|
3184
|
+
if tpgid != pgid {
|
|
3185
|
+
return WorkerRuntimeState::Busy;
|
|
3186
|
+
}
|
|
3187
|
+
// Same pgrp — pane process owns foreground. Defer to
|
|
3188
|
+
// JSONL/activity for finer state.
|
|
3189
|
+
}
|
|
3190
|
+
Ok(None) => {
|
|
3191
|
+
// Probe could not read: missing PID, no controlling
|
|
3192
|
+
// terminal, ps unavailable. Fall through to activity
|
|
3193
|
+
// classifier; if that's also missing/uncertain, the
|
|
3194
|
+
// function returns Unknown.
|
|
3195
|
+
}
|
|
3196
|
+
Err(_) => {
|
|
3197
|
+
// Subprocess error — degrade to Unknown unless activity
|
|
3198
|
+
// is decisive.
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// §4.4/4.5 Activity-derived classification.
|
|
3204
|
+
if let Some(activity) = activity {
|
|
3205
|
+
return match activity.status {
|
|
3206
|
+
ActivityStatus::Working => WorkerRuntimeState::Busy,
|
|
3207
|
+
ActivityStatus::Idle => WorkerRuntimeState::ProbablyIdle,
|
|
3208
|
+
ActivityStatus::Stuck | ActivityStatus::Uncertain => WorkerRuntimeState::Unknown,
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
WorkerRuntimeState::Unknown
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3129
3215
|
fn activity_status_wire(status: crate::messaging::ActivityStatus) -> &'static str {
|
|
3130
3216
|
match status {
|
|
3131
3217
|
crate::messaging::ActivityStatus::Idle => "idle",
|
|
@@ -3144,6 +3230,16 @@ fn agent_health_status_wire(status: crate::messaging::ActivityStatus) -> &'stati
|
|
|
3144
3230
|
}
|
|
3145
3231
|
}
|
|
3146
3232
|
|
|
3233
|
+
/// 0.4.x Phase 1: read the worker_state wire string that `write_activity`
|
|
3234
|
+
/// persisted alongside the legacy activity. Returns None when no worker_state
|
|
3235
|
+
/// has been written yet (older state row pre-upgrade).
|
|
3236
|
+
pub(crate) fn agent_worker_state(agent: &Value) -> Option<crate::messaging::WorkerRuntimeState> {
|
|
3237
|
+
agent
|
|
3238
|
+
.get("worker_state")
|
|
3239
|
+
.and_then(Value::as_str)
|
|
3240
|
+
.map(crate::messaging::WorkerRuntimeState::parse_wire)
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3147
3243
|
fn write_agent_health(
|
|
3148
3244
|
store: &crate::message_store::MessageStore,
|
|
3149
3245
|
team: &str,
|
|
@@ -448,16 +448,57 @@ fn ensure_managed_leader_pane(
|
|
|
448
448
|
});
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
|
+
// 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
|
|
452
|
+
// provider_env_unsets (single source of truth) + spawn through the
|
|
453
|
+
// leader shell wrapper so provider exit returns to a shell, not
|
|
454
|
+
// `[exited]`.
|
|
455
|
+
let env_unset = leader_env_unset_for_provider(plan.provider);
|
|
456
|
+
let provider_label = provider_command_name(plan.provider);
|
|
451
457
|
transport
|
|
452
|
-
.
|
|
458
|
+
.spawn_into_with_leader_shell_wrapper(
|
|
459
|
+
session,
|
|
460
|
+
window,
|
|
461
|
+
&plan.provider_argv,
|
|
462
|
+
workspace,
|
|
463
|
+
&plan.leader_env,
|
|
464
|
+
&env_unset,
|
|
465
|
+
provider_label,
|
|
466
|
+
)
|
|
453
467
|
.map_err(|error| LeaderError::Start(error.to_string()))
|
|
454
468
|
} else {
|
|
469
|
+
let env_unset = leader_env_unset_for_provider(plan.provider);
|
|
470
|
+
let provider_label = provider_command_name(plan.provider);
|
|
455
471
|
transport
|
|
456
|
-
.
|
|
472
|
+
.spawn_first_with_leader_shell_wrapper(
|
|
473
|
+
session,
|
|
474
|
+
window,
|
|
475
|
+
&plan.provider_argv,
|
|
476
|
+
workspace,
|
|
477
|
+
&plan.leader_env,
|
|
478
|
+
&env_unset,
|
|
479
|
+
provider_label,
|
|
480
|
+
)
|
|
457
481
|
.map_err(|error| LeaderError::Start(error.to_string()))
|
|
458
482
|
}
|
|
459
483
|
}
|
|
460
484
|
|
|
485
|
+
/// 0.4.x (CR C-1): leader provider env-unset list — SINGLE SOURCE OF TRUTH
|
|
486
|
+
/// reused from worker spawn (`profile_launch::provider_env_unsets`).
|
|
487
|
+
/// Audit grep guard: this function MUST be the only place in
|
|
488
|
+
/// `crates/team-agent/src/leader/` that constructs a Claude/Codex/Copilot
|
|
489
|
+
/// env-unset list. Any new code path that needs it must call this function
|
|
490
|
+
/// or the underlying `provider_env_unsets`. Use `AuthMode::Subscription` —
|
|
491
|
+
/// the leader is the user's interactive provider, never CompatibleApi/
|
|
492
|
+
/// OfficialApi which are worker-only auth modes today.
|
|
493
|
+
pub fn leader_env_unset_for_provider(provider: Provider) -> Vec<String> {
|
|
494
|
+
crate::lifecycle::profile_launch::provider_env_unsets(
|
|
495
|
+
provider,
|
|
496
|
+
crate::model::enums::AuthMode::Subscription,
|
|
497
|
+
)
|
|
498
|
+
.into_iter()
|
|
499
|
+
.collect()
|
|
500
|
+
}
|
|
501
|
+
|
|
461
502
|
fn ensure_managed_provider_live_after_attach(
|
|
462
503
|
transport: &dyn Transport,
|
|
463
504
|
spawned: &SpawnResult,
|
|
@@ -478,6 +519,117 @@ fn ensure_managed_provider_live_after_attach(
|
|
|
478
519
|
)))
|
|
479
520
|
}
|
|
480
521
|
|
|
522
|
+
/// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
|
|
523
|
+
/// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
|
|
524
|
+
/// returns `Live` even when the provider has exited and the wrapper shell
|
|
525
|
+
/// fell back to an interactive shell. This function distinguishes
|
|
526
|
+
/// `provider_alive` from `provider_exited` by:
|
|
527
|
+
/// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
|
|
528
|
+
/// 2. If the current command matches the expected provider binary (or
|
|
529
|
+
/// one of its known aliases), report `Alive`.
|
|
530
|
+
/// 3. If the current command is an interactive shell AND the pane
|
|
531
|
+
/// content contains the exit marker `[team-agent] <provider> exited`
|
|
532
|
+
/// (emitted by `leader_shell_wrapper_command`), report
|
|
533
|
+
/// `ProviderExited`.
|
|
534
|
+
/// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
|
|
535
|
+
/// conservative default — avoid false-positive exit alarms.
|
|
536
|
+
///
|
|
537
|
+
/// Note: when the leader shell wrapper is used (CR C-2), a provider exit
|
|
538
|
+
/// leaves the pane as `<SHELL:-/bin/zsh>` with the exit marker in
|
|
539
|
+
/// scrollback. Pre-wrapper code that hit `exec claude` would have left the
|
|
540
|
+
/// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
|
|
541
|
+
/// failure mode requires this richer health check to surface
|
|
542
|
+
/// `leader_provider_exited` as a distinct status.
|
|
543
|
+
pub fn leader_provider_health(
|
|
544
|
+
transport: &dyn Transport,
|
|
545
|
+
pane_id: &PaneId,
|
|
546
|
+
expected_provider_label: &str,
|
|
547
|
+
) -> LeaderProviderHealth {
|
|
548
|
+
use crate::transport::{CaptureRange, PaneField, Target};
|
|
549
|
+
let liveness = transport.liveness(pane_id).ok();
|
|
550
|
+
if matches!(liveness, Some(PaneLiveness::Dead)) {
|
|
551
|
+
return LeaderProviderHealth::Unreachable;
|
|
552
|
+
}
|
|
553
|
+
let target = Target::Pane(pane_id.clone());
|
|
554
|
+
let current_command = transport
|
|
555
|
+
.query(&target, PaneField::PaneCurrentCommand)
|
|
556
|
+
.ok()
|
|
557
|
+
.flatten()
|
|
558
|
+
.map(|s| s.trim().to_lowercase())
|
|
559
|
+
.unwrap_or_default();
|
|
560
|
+
if !current_command.is_empty()
|
|
561
|
+
&& (current_command == expected_provider_label
|
|
562
|
+
|| current_command.contains(expected_provider_label))
|
|
563
|
+
{
|
|
564
|
+
return LeaderProviderHealth::Alive;
|
|
565
|
+
}
|
|
566
|
+
// Current command is NOT the provider — likely fell back to shell.
|
|
567
|
+
let is_shell = is_interactive_shell_basename(¤t_command);
|
|
568
|
+
if is_shell {
|
|
569
|
+
// CR R6: marker text from single-source `leader_provider_exit_marker`
|
|
570
|
+
// so the wrapper printf and the health-check substring cannot drift.
|
|
571
|
+
let exit_marker_substr =
|
|
572
|
+
crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
|
|
573
|
+
if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
|
|
574
|
+
if cap.text.contains(&exit_marker_substr) {
|
|
575
|
+
return LeaderProviderHealth::ProviderExited;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// Conservative default — pane addressable, but couldn't positively
|
|
580
|
+
// confirm provider exit. Treat as Alive.
|
|
581
|
+
LeaderProviderHealth::Alive
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/// Health status reported by [`leader_provider_health`].
|
|
585
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
586
|
+
pub enum LeaderProviderHealth {
|
|
587
|
+
/// Provider process appears to be the pane's current command.
|
|
588
|
+
Alive,
|
|
589
|
+
/// Pane has fallen back to a shell with the exit marker present —
|
|
590
|
+
/// provider has exited.
|
|
591
|
+
ProviderExited,
|
|
592
|
+
/// Pane is dead / unaddressable.
|
|
593
|
+
Unreachable,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/// 0.4.x (CR R6 + R3): single-source interactive-shell detection.
|
|
597
|
+
/// Used by:
|
|
598
|
+
/// - `leader_provider_health` to decide "pane fell back to shell"
|
|
599
|
+
/// - shutdown logic to recognise a leader pane in fallback-shell mode
|
|
600
|
+
/// as still owned by the leader (not stray).
|
|
601
|
+
///
|
|
602
|
+
/// Matches by basename (case-insensitive) — `pane_current_command` returns
|
|
603
|
+
/// the basename of the running binary. Conservative whitelist of POSIX +
|
|
604
|
+
/// common interactive shells; missing entries here are false negatives
|
|
605
|
+
/// (shell looks like provider absent → health says Alive) which is the
|
|
606
|
+
/// safe default per the CR R6 conservative-Alive rule.
|
|
607
|
+
pub fn is_interactive_shell_basename(name: &str) -> bool {
|
|
608
|
+
let trimmed = name.trim().to_ascii_lowercase();
|
|
609
|
+
let basename = std::path::Path::new(&trimmed)
|
|
610
|
+
.file_name()
|
|
611
|
+
.and_then(|s| s.to_str())
|
|
612
|
+
.unwrap_or(&trimmed);
|
|
613
|
+
matches!(
|
|
614
|
+
basename,
|
|
615
|
+
"zsh"
|
|
616
|
+
| "bash"
|
|
617
|
+
| "sh"
|
|
618
|
+
| "fish"
|
|
619
|
+
| "dash"
|
|
620
|
+
| "ksh"
|
|
621
|
+
| "tcsh"
|
|
622
|
+
| "csh"
|
|
623
|
+
| "ash"
|
|
624
|
+
| "mksh"
|
|
625
|
+
| "yash"
|
|
626
|
+
| "elvish"
|
|
627
|
+
| "nu"
|
|
628
|
+
| "nushell"
|
|
629
|
+
| "xonsh"
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
481
633
|
fn managed_spawned_pane_in_targets(transport: &dyn Transport, spawned: &SpawnResult) -> bool {
|
|
482
634
|
transport
|
|
483
635
|
.list_targets()
|
|
@@ -785,13 +937,35 @@ fn run_leader_argv(
|
|
|
785
937
|
"leader launch argv is empty".to_string(),
|
|
786
938
|
));
|
|
787
939
|
};
|
|
788
|
-
|
|
940
|
+
// 0.4.x regression fix (env-leak scenario 1, in-tmux ExecProvider path):
|
|
941
|
+
// the managed-tmux path got the provider env-unset block via the shell
|
|
942
|
+
// wrapper (cb9c217), but the ExecProvider in-tmux path here is a direct
|
|
943
|
+
// Command::spawn().wait() — it inherits the parent process's full env
|
|
944
|
+
// including any CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_CHILD_SESSION /
|
|
945
|
+
// CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS / CLAUDE_CODE_ENTRYPOINT /
|
|
946
|
+
// CLAUDE_CODE_EXECPATH / CLAUDECODE that the launching shell carries.
|
|
947
|
+
// Apply the SAME env-unset list used by the managed path (CR R6 single
|
|
948
|
+
// source: profile_launch::provider_env_unsets via
|
|
949
|
+
// leader_env_unset_for_provider).
|
|
950
|
+
let env_unset = leader_env_unset_for_provider(plan.provider);
|
|
951
|
+
let mut command = Command::new(program);
|
|
952
|
+
command
|
|
789
953
|
.args(argv.iter().skip(1))
|
|
790
|
-
.envs(env)
|
|
791
954
|
.stdin(Stdio::inherit())
|
|
792
955
|
.stdout(Stdio::inherit())
|
|
793
|
-
.stderr(Stdio::inherit())
|
|
794
|
-
|
|
956
|
+
.stderr(Stdio::inherit());
|
|
957
|
+
// 0.4.x order fix: env_remove MUST run AFTER envs(). The `env` map comes
|
|
958
|
+
// from `merged_exec_env` which seeds with `std::env::vars().collect()` —
|
|
959
|
+
// calling `.envs(env)` re-adds every inherited CLAUDE_CODE_* the launching
|
|
960
|
+
// shell carried, overwriting any prior env_remove. By removing AFTER the
|
|
961
|
+
// bulk envs() call, the final Command env table has the leak keys
|
|
962
|
+
// structurally absent. Verified by the regression grep guard that the
|
|
963
|
+
// env_remove call appears AFTER `command.envs(env)`.
|
|
964
|
+
command.envs(env);
|
|
965
|
+
for key in &env_unset {
|
|
966
|
+
command.env_remove(key);
|
|
967
|
+
}
|
|
968
|
+
let mut child = command.spawn()?;
|
|
795
969
|
if plan.mode == LeaderStartMode::ExecProvider {
|
|
796
970
|
spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
|
|
797
971
|
}
|