@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
|
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
|
|
4
4
|
use std::path::{Path, PathBuf};
|
|
5
5
|
use std::process::Command;
|
|
6
6
|
|
|
7
|
-
use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider};
|
|
7
|
+
use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
|
|
8
8
|
use crate::model::ids::AgentId;
|
|
9
9
|
use crate::model::permissions::{self, AgentPermissionInput};
|
|
10
10
|
use crate::model::yaml::{self, Value};
|
|
@@ -243,6 +243,16 @@ fn spawn_agents(
|
|
|
243
243
|
Some(&mcp_config),
|
|
244
244
|
)?;
|
|
245
245
|
let command_model = profile_launch.command_overrides.model.as_deref().or(model);
|
|
246
|
+
// 0.4.x provider effort MVP step 4 + 7: resolve effort and emit
|
|
247
|
+
// unsupported warning event when the spec asked for an effort the
|
|
248
|
+
// provider can't satisfy.
|
|
249
|
+
let agent_effort = provider_effort_for_spawn(agent, provider);
|
|
250
|
+
if let Some(event_value) =
|
|
251
|
+
provider_effort_event_if_dropped(agent, provider, agent_id_raw)
|
|
252
|
+
{
|
|
253
|
+
let _ = crate::event_log::EventLog::new(workspace)
|
|
254
|
+
.write("provider.effort_unsupported", event_value);
|
|
255
|
+
}
|
|
246
256
|
let mut plan = adapter
|
|
247
257
|
.build_command_plan(crate::provider::ProviderCommandContext {
|
|
248
258
|
auth_mode,
|
|
@@ -256,6 +266,7 @@ fn spawn_agents(
|
|
|
256
266
|
// adapters can pass `--name <agent_id>`. Codex has no
|
|
257
267
|
// equivalent flag and ignores the hint.
|
|
258
268
|
agent_id_hint: Some(agent_id_raw),
|
|
269
|
+
effort: agent_effort,
|
|
259
270
|
})
|
|
260
271
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
261
272
|
if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
|
|
@@ -320,7 +331,10 @@ fn spawn_agents(
|
|
|
320
331
|
apply_mcp_auto_approval_env(&mut env, &safety);
|
|
321
332
|
// Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
|
|
322
333
|
// with cwd=workspace, same as the RS fork/add and restart paths.
|
|
323
|
-
let env_unset: Vec<String> =
|
|
334
|
+
let env_unset: Vec<String> = extend_worker_env_unset_for_effort(
|
|
335
|
+
profile_launch.env_unset.iter().cloned().collect(),
|
|
336
|
+
provider,
|
|
337
|
+
);
|
|
324
338
|
// BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
|
|
325
339
|
// per-worker AGENTS.md(B2 灵魂件降级):写
|
|
326
340
|
// <workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md
|
|
@@ -1721,6 +1735,13 @@ fn running_agent_state(
|
|
|
1721
1735
|
model.map_or(serde_json::Value::Null, |m| serde_json::json!(m)),
|
|
1722
1736
|
);
|
|
1723
1737
|
state.insert("auth_mode".to_string(), serde_json::json!(auth_mode));
|
|
1738
|
+
// 0.4.x provider effort MVP step 8: persist resolved effort so restart
|
|
1739
|
+
// / resume reads the same value (no re-resolution from role/team).
|
|
1740
|
+
if let Some(effort_str) = agent.get("effort").and_then(Value::as_str) {
|
|
1741
|
+
if !effort_str.is_empty() {
|
|
1742
|
+
state.insert("effort".to_string(), serde_json::json!(effort_str));
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1724
1745
|
state.insert("profile".to_string(), profile);
|
|
1725
1746
|
if agent.get("profile").is_some() {
|
|
1726
1747
|
if let Some(profile_dir) = profile_dir {
|
|
@@ -2466,6 +2487,112 @@ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
|
|
|
2466
2487
|
}
|
|
2467
2488
|
}
|
|
2468
2489
|
|
|
2490
|
+
/// 0.4.x provider effort MVP step 4: low-level from a raw string. Returns
|
|
2491
|
+
/// `Some(effort)` when the level parses AND the provider supports it.
|
|
2492
|
+
pub(crate) fn provider_effort_from_raw(
|
|
2493
|
+
raw: Option<&str>,
|
|
2494
|
+
provider: Provider,
|
|
2495
|
+
) -> Option<ProviderEffort> {
|
|
2496
|
+
let raw = raw?.trim();
|
|
2497
|
+
if raw.is_empty() {
|
|
2498
|
+
return None;
|
|
2499
|
+
}
|
|
2500
|
+
let effort = ProviderEffort::parse(raw)?;
|
|
2501
|
+
if effort.is_supported_by(provider) {
|
|
2502
|
+
Some(effort)
|
|
2503
|
+
} else {
|
|
2504
|
+
None
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/// 0.4.x provider effort MVP step 7: warning event payload when the spec
|
|
2509
|
+
/// requested an effort level the provider does not support.
|
|
2510
|
+
pub(crate) fn provider_effort_event_payload(
|
|
2511
|
+
raw: Option<&str>,
|
|
2512
|
+
provider: Provider,
|
|
2513
|
+
agent_id: &str,
|
|
2514
|
+
) -> Option<serde_json::Value> {
|
|
2515
|
+
let raw = raw?.trim();
|
|
2516
|
+
if raw.is_empty() {
|
|
2517
|
+
return None;
|
|
2518
|
+
}
|
|
2519
|
+
let effort = ProviderEffort::parse(raw)?;
|
|
2520
|
+
if effort.is_supported_by(provider) {
|
|
2521
|
+
return None;
|
|
2522
|
+
}
|
|
2523
|
+
Some(serde_json::json!({
|
|
2524
|
+
"agent_id": agent_id,
|
|
2525
|
+
"provider": format!("{provider:?}").to_lowercase(),
|
|
2526
|
+
"effort": effort.as_str(),
|
|
2527
|
+
"action": "ignored",
|
|
2528
|
+
"reason": "provider does not support effort",
|
|
2529
|
+
}))
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/// 0.4.x provider effort MVP step 9: defensive guarantee that `CLAUDE_EFFORT`
|
|
2533
|
+
/// is unset in the Claude/ClaudeCode worker spawn env. As of the
|
|
2534
|
+
/// `profile_launch::provider_env_unsets` update, the base list already
|
|
2535
|
+
/// includes `CLAUDE_EFFORT` for Claude — so this function is idempotent
|
|
2536
|
+
/// (returns input unchanged). Kept as a belt-and-braces guard so a future
|
|
2537
|
+
/// refactor that bypasses provider_env_unsets cannot silently drop the
|
|
2538
|
+
/// scrub. The structural win is in `tmux_backend::shell_command` which now
|
|
2539
|
+
/// filters env exports by env_unset (preventing inherited values from
|
|
2540
|
+
/// re-introducing keys we just unset).
|
|
2541
|
+
pub(crate) fn extend_worker_env_unset_for_effort(
|
|
2542
|
+
base: Vec<String>,
|
|
2543
|
+
provider: Provider,
|
|
2544
|
+
) -> Vec<String> {
|
|
2545
|
+
if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
2546
|
+
return base;
|
|
2547
|
+
}
|
|
2548
|
+
let mut out = base;
|
|
2549
|
+
if !out.iter().any(|k| k == "CLAUDE_EFFORT") {
|
|
2550
|
+
out.push("CLAUDE_EFFORT".to_string());
|
|
2551
|
+
}
|
|
2552
|
+
out
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
/// Convenience: resolve effort for a yaml::Value agent (spec / compiled).
|
|
2556
|
+
pub(crate) fn provider_effort_for_spawn(
|
|
2557
|
+
agent: &crate::model::yaml::Value,
|
|
2558
|
+
provider: Provider,
|
|
2559
|
+
) -> Option<ProviderEffort> {
|
|
2560
|
+
provider_effort_from_raw(agent.get("effort").and_then(|v| v.as_str()), provider)
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
pub(crate) fn provider_effort_event_if_dropped(
|
|
2564
|
+
agent: &crate::model::yaml::Value,
|
|
2565
|
+
provider: Provider,
|
|
2566
|
+
agent_id: &str,
|
|
2567
|
+
) -> Option<serde_json::Value> {
|
|
2568
|
+
provider_effort_event_payload(
|
|
2569
|
+
agent.get("effort").and_then(|v| v.as_str()),
|
|
2570
|
+
provider,
|
|
2571
|
+
agent_id,
|
|
2572
|
+
)
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
/// Same as [`provider_effort_for_spawn`] but for serde_json state values
|
|
2576
|
+
/// (used by restart paths reading from `state.agents[id]`).
|
|
2577
|
+
pub(crate) fn provider_effort_for_spawn_json(
|
|
2578
|
+
agent: &serde_json::Value,
|
|
2579
|
+
provider: Provider,
|
|
2580
|
+
) -> Option<ProviderEffort> {
|
|
2581
|
+
provider_effort_from_raw(agent.get("effort").and_then(serde_json::Value::as_str), provider)
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
pub(crate) fn provider_effort_event_if_dropped_json(
|
|
2585
|
+
agent: &serde_json::Value,
|
|
2586
|
+
provider: Provider,
|
|
2587
|
+
agent_id: &str,
|
|
2588
|
+
) -> Option<serde_json::Value> {
|
|
2589
|
+
provider_effort_event_payload(
|
|
2590
|
+
agent.get("effort").and_then(serde_json::Value::as_str),
|
|
2591
|
+
provider,
|
|
2592
|
+
agent_id,
|
|
2593
|
+
)
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2469
2596
|
fn quick_start_requested_team_key<'a>(
|
|
2470
2597
|
team_id: Option<&'a str>,
|
|
2471
2598
|
name: Option<&'a str>,
|
|
@@ -3786,6 +3913,16 @@ fn upsert_agent_state_from_role(
|
|
|
3786
3913
|
}
|
|
3787
3914
|
}
|
|
3788
3915
|
}
|
|
3916
|
+
// 0.4.x provider effort MVP step 8 (dynamic add-agent): persist effort
|
|
3917
|
+
// from the role doc front matter (compiler.rs validates syntax/semantics
|
|
3918
|
+
// at compile; add-agent path validates here too in case of direct YAML).
|
|
3919
|
+
if let Some(effort_str) = meta.get("effort").and_then(Value::as_str) {
|
|
3920
|
+
if !effort_str.is_empty() {
|
|
3921
|
+
if let Some(obj) = entry.as_object_mut() {
|
|
3922
|
+
obj.insert("effort".to_string(), serde_json::json!(effort_str));
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3789
3926
|
if let Some(obj) = entry.as_object_mut() {
|
|
3790
3927
|
persist_effective_approval_policy(obj, safety);
|
|
3791
3928
|
}
|
|
@@ -4075,6 +4212,15 @@ pub fn fork_agent_with_transport(
|
|
|
4075
4212
|
Some(&mcp_config),
|
|
4076
4213
|
)?;
|
|
4077
4214
|
let command_model = profile_launch.command_overrides.model.as_deref().or(model);
|
|
4215
|
+
// 0.4.x provider effort MVP: fork inherits effort from the new agent JSON
|
|
4216
|
+
// (compiler.rs propagated the role/team effort into the agent at fork-spawn).
|
|
4217
|
+
let fork_effort = provider_effort_for_spawn(new_agent, provider);
|
|
4218
|
+
if let Some(event_value) =
|
|
4219
|
+
provider_effort_event_if_dropped(new_agent, provider, as_agent_id.as_str())
|
|
4220
|
+
{
|
|
4221
|
+
let _ = crate::event_log::EventLog::new(&workspace)
|
|
4222
|
+
.write("provider.effort_unsupported", event_value);
|
|
4223
|
+
}
|
|
4078
4224
|
let mut plan = adapter
|
|
4079
4225
|
.fork_plan(
|
|
4080
4226
|
Some(&session_id),
|
|
@@ -4086,6 +4232,7 @@ pub fn fork_agent_with_transport(
|
|
|
4086
4232
|
tools: &resolved_tool_refs,
|
|
4087
4233
|
profile_launch: Some(&profile_launch),
|
|
4088
4234
|
agent_id_hint: Some(as_agent_id.as_str()),
|
|
4235
|
+
effort: fork_effort,
|
|
4089
4236
|
},
|
|
4090
4237
|
)
|
|
4091
4238
|
.map_err(|e| {
|
|
@@ -4111,7 +4258,10 @@ pub fn fork_agent_with_transport(
|
|
|
4111
4258
|
// _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
|
|
4112
4259
|
// (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
|
|
4113
4260
|
let session_live = transport.has_session(&session_name).unwrap_or(false);
|
|
4114
|
-
let env_unset: Vec<String> =
|
|
4261
|
+
let env_unset: Vec<String> = extend_worker_env_unset_for_effort(
|
|
4262
|
+
profile_launch.env_unset.iter().cloned().collect(),
|
|
4263
|
+
provider,
|
|
4264
|
+
);
|
|
4115
4265
|
let spawn_result = if session_live {
|
|
4116
4266
|
transport.spawn_into_with_env_unset(
|
|
4117
4267
|
&session_name,
|
|
@@ -4495,6 +4645,8 @@ fn upsert_forked_agent_state(
|
|
|
4495
4645
|
"profile",
|
|
4496
4646
|
"_profile_dir",
|
|
4497
4647
|
"role",
|
|
4648
|
+
// 0.4.x provider effort MVP step 8: fork inherits compiled effort.
|
|
4649
|
+
"effort",
|
|
4498
4650
|
] {
|
|
4499
4651
|
if let Some(value) = spec_agent.get(key) {
|
|
4500
4652
|
entry.insert(key.to_string(), yaml_value_to_json(value));
|
|
@@ -415,7 +415,14 @@ fn provider_env_exports(
|
|
|
415
415
|
exports
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
-
|
|
418
|
+
/// 0.4.x (CR C-1): exposed as `pub(crate)` so the leader launcher
|
|
419
|
+
/// (leader/start.rs) can reuse the SAME Claude/Codex/Copilot env-unset set
|
|
420
|
+
/// used by worker spawn. Single source of truth — adding a new provider env
|
|
421
|
+
/// var to clean up means changing this one function. Audit grep guard in
|
|
422
|
+
/// `crates/team-agent/src/leader/start.rs::leader_env_unset_for_provider`
|
|
423
|
+
/// confirms the leader path imports from here and does not maintain its own
|
|
424
|
+
/// duplicate list.
|
|
425
|
+
pub(crate) fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<String> {
|
|
419
426
|
let mut unsets = BTreeSet::new();
|
|
420
427
|
match provider {
|
|
421
428
|
Provider::Claude | Provider::ClaudeCode => {
|
|
@@ -443,6 +450,18 @@ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<Stri
|
|
|
443
450
|
unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
|
|
444
451
|
unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
|
|
445
452
|
unsets.insert("CLAUDECODE".to_string());
|
|
453
|
+
// 0.4.x provider effort MVP step 9: CLAUDE_EFFORT carried from the
|
|
454
|
+
// launching shell would silently override the framework's effort
|
|
455
|
+
// decision (or, when no spec effort is configured, override the
|
|
456
|
+
// documented "provider default"). Scrubbing here is the single
|
|
457
|
+
// source — `extend_worker_env_unset_for_effort` is a thin alias
|
|
458
|
+
// that calls into this list. Applies to BOTH worker spawn AND
|
|
459
|
+
// leader spawn (leader_env_unset_for_provider also reads this).
|
|
460
|
+
// MVP scope: leader-effort is not configured yet, but if a
|
|
461
|
+
// launching shell already carries CLAUDE_EFFORT, the leader
|
|
462
|
+
// inheriting it would conflict with future leader-effort
|
|
463
|
+
// semantics. Scrub uniformly for predictability.
|
|
464
|
+
unsets.insert("CLAUDE_EFFORT".to_string());
|
|
446
465
|
if auth_mode == AuthMode::CompatibleApi {
|
|
447
466
|
unsets.insert("ANTHROPIC_API_KEY".to_string());
|
|
448
467
|
}
|
|
@@ -1058,6 +1058,15 @@ fn write_start_agent_start_event(
|
|
|
1058
1058
|
.model
|
|
1059
1059
|
.as_deref()
|
|
1060
1060
|
.or(model);
|
|
1061
|
+
// 0.4.x provider effort MVP: start_agent path preserves effort from the
|
|
1062
|
+
// persisted agent JSON.
|
|
1063
|
+
let start_agent_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(&agent, provider);
|
|
1064
|
+
if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
|
|
1065
|
+
&agent, provider, agent_id.as_str(),
|
|
1066
|
+
) {
|
|
1067
|
+
let _ = crate::event_log::EventLog::new(workspace)
|
|
1068
|
+
.write("provider.effort_unsupported", event_value);
|
|
1069
|
+
}
|
|
1061
1070
|
let context = crate::provider::ProviderCommandContext {
|
|
1062
1071
|
auth_mode,
|
|
1063
1072
|
mcp_config: Some(&mcp_config),
|
|
@@ -1066,6 +1075,7 @@ fn write_start_agent_start_event(
|
|
|
1066
1075
|
tools: &resolved_tool_refs,
|
|
1067
1076
|
profile_launch: Some(&profile_launch),
|
|
1068
1077
|
agent_id_hint: Some(agent_id.as_str()),
|
|
1078
|
+
effort: start_agent_effort,
|
|
1069
1079
|
};
|
|
1070
1080
|
let mut plan = match session_id {
|
|
1071
1081
|
Some(session_id) => adapter
|
|
@@ -114,6 +114,15 @@ pub(super) fn spawn_agent_window(
|
|
|
114
114
|
Some(&mcp_config),
|
|
115
115
|
)?;
|
|
116
116
|
let command_model = profile_launch.command_overrides.model.as_deref().or(model);
|
|
117
|
+
// 0.4.x provider effort MVP: restart/resume preserves effort from the
|
|
118
|
+
// persisted agent JSON (state's agent["effort"] field, set by launch).
|
|
119
|
+
let restart_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(agent, provider);
|
|
120
|
+
if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
|
|
121
|
+
agent, provider, agent_id.as_str(),
|
|
122
|
+
) {
|
|
123
|
+
let _ = crate::event_log::EventLog::new(workspace)
|
|
124
|
+
.write("provider.effort_unsupported", event_value);
|
|
125
|
+
}
|
|
117
126
|
let context = crate::provider::ProviderCommandContext {
|
|
118
127
|
auth_mode,
|
|
119
128
|
mcp_config: Some(&mcp_config),
|
|
@@ -122,6 +131,7 @@ pub(super) fn spawn_agent_window(
|
|
|
122
131
|
tools: &resolved_tool_refs,
|
|
123
132
|
profile_launch: Some(&profile_launch),
|
|
124
133
|
agent_id_hint: Some(agent_id.as_str()),
|
|
134
|
+
effort: restart_effort,
|
|
125
135
|
};
|
|
126
136
|
let mut plan = match resume_session_id {
|
|
127
137
|
Some(session_id) => adapter
|
|
@@ -167,7 +177,13 @@ pub(super) fn spawn_agent_window(
|
|
|
167
177
|
// a per-agent YAML `spawn_cwd` field if one is set. Until then, override
|
|
168
178
|
// > workspace; state-based override is silently dropped.
|
|
169
179
|
let spawn_cwd = spawn_cwd_override.unwrap_or(workspace);
|
|
170
|
-
|
|
180
|
+
// 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
|
|
181
|
+
// worker spawn so a parent shell env cannot silently override the
|
|
182
|
+
// framework's effort decision.
|
|
183
|
+
let env_unset: Vec<String> = crate::lifecycle::launch::extend_worker_env_unset_for_effort(
|
|
184
|
+
profile_launch.env_unset.iter().cloned().collect(),
|
|
185
|
+
provider,
|
|
186
|
+
);
|
|
171
187
|
|
|
172
188
|
// 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
|
|
173
189
|
// transport spawn. Mirrors `launch.rs:359-380` (the reference impl)
|
|
@@ -102,7 +102,8 @@ pub use types::{
|
|
|
102
102
|
CheckKind, CheckStatus, ContractSuiteCheck, DeliveryOutcome, DeliveryRefusal, DeliveryStage,
|
|
103
103
|
DeliveryStatus, IdleEvaluation, LeaderNotificationKey, LeaderReceiver, PaneWidthQuery,
|
|
104
104
|
ProviderSdkCalls, ReceiverMode, ScheduledKind, SelftestCheck, SelftestReport, SendEventPayload,
|
|
105
|
-
TrustRetryPayload, WatcherNotice,
|
|
105
|
+
TrustRetryPayload, WatcherNotice, WorkerRuntimeState,
|
|
106
|
+
RESULT_DELIVERY_MAX_ATTEMPTS, SEND_RETRY_MAX_ATTEMPTS,
|
|
106
107
|
TRUST_RETRY_BACKOFF_SECONDS, TRUST_RETRY_MAX_ATTEMPTS,
|
|
107
108
|
};
|
|
108
109
|
pub use watchers::{
|
|
@@ -126,6 +126,92 @@ impl ActivityStatus {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/// 0.4.x Phase 1 worker runtime-state (fg-pgrp + 5-state plan, CR APPROVED).
|
|
130
|
+
///
|
|
131
|
+
/// Canonical runtime-state enum surfaced by `team-agent status` and consumed
|
|
132
|
+
/// by automatic-dispatch / idle-gate predicates. Distinct from
|
|
133
|
+
/// [`ActivityStatus`] (which remains the internal classifier output for
|
|
134
|
+
/// backwards compatibility) and from lifecycle `agents.<id>.status` (which
|
|
135
|
+
/// remains launch/stop-owned: `running`/`paused`/`stopped`/etc.).
|
|
136
|
+
///
|
|
137
|
+
/// Rules:
|
|
138
|
+
/// * `Busy` when `foreground_pgrp != agent_root_pgrp` (a child process
|
|
139
|
+
/// occupies the terminal — provider is not at idle prompt).
|
|
140
|
+
/// * `Unknown` is NEVER idle-eligible and must be treated as busy by
|
|
141
|
+
/// automatic-dispatch / idle-gate predicates.
|
|
142
|
+
/// * `ProbablyIdle` is a weak conclusion, not proof of idleness.
|
|
143
|
+
/// * `Dead` when the pane is missing or the root process is gone.
|
|
144
|
+
/// * `Blocked` when the worker is awaiting human confirmation (trust
|
|
145
|
+
/// prompt, startup approval).
|
|
146
|
+
///
|
|
147
|
+
/// CR R4: deserialise with `#[serde(other)]` so a future variant added by
|
|
148
|
+
/// a newer build does not crash an older reader (rolling upgrade safe).
|
|
149
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
150
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
151
|
+
pub enum WorkerRuntimeState {
|
|
152
|
+
Dead,
|
|
153
|
+
Busy,
|
|
154
|
+
Blocked,
|
|
155
|
+
ProbablyIdle,
|
|
156
|
+
/// Default fallback. CR R4: also catches unknown wire strings via
|
|
157
|
+
/// `#[serde(other)]`.
|
|
158
|
+
#[serde(other)]
|
|
159
|
+
Unknown,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
impl WorkerRuntimeState {
|
|
163
|
+
/// Wire string used by status/diagnose JSON.
|
|
164
|
+
pub fn as_wire(self) -> &'static str {
|
|
165
|
+
match self {
|
|
166
|
+
Self::Dead => "DEAD",
|
|
167
|
+
Self::Busy => "BUSY",
|
|
168
|
+
Self::Blocked => "BLOCKED",
|
|
169
|
+
Self::ProbablyIdle => "PROBABLY_IDLE",
|
|
170
|
+
Self::Unknown => "UNKNOWN",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Parse from wire string. Returns `Self::Unknown` for unknown literals
|
|
175
|
+
/// (defence-in-depth alongside `#[serde(other)]`).
|
|
176
|
+
pub fn parse_wire(value: &str) -> Self {
|
|
177
|
+
match value.trim() {
|
|
178
|
+
"DEAD" => Self::Dead,
|
|
179
|
+
"BUSY" => Self::Busy,
|
|
180
|
+
"BLOCKED" => Self::Blocked,
|
|
181
|
+
"PROBABLY_IDLE" => Self::ProbablyIdle,
|
|
182
|
+
_ => Self::Unknown,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// True for states that MUST NOT receive automatic dispatch:
|
|
187
|
+
/// `Dead | Busy | Blocked | Unknown`. Only `ProbablyIdle` is safe.
|
|
188
|
+
/// CR R3 + Phase 1 §8 dispatch boundary.
|
|
189
|
+
pub fn blocks_automatic_dispatch(self) -> bool {
|
|
190
|
+
!matches!(self, Self::ProbablyIdle)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// True only for `ProbablyIdle` — the sole idle candidate. `Unknown` is
|
|
194
|
+
/// NEVER idle (Iron Law: bug-071/077/085).
|
|
195
|
+
pub fn is_idle_candidate(self) -> bool {
|
|
196
|
+
matches!(self, Self::ProbablyIdle)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// Map from the legacy [`ActivityStatus`] for backward-compat. Used by
|
|
200
|
+
/// status/diagnose code paths that have an old AgentActivity in hand
|
|
201
|
+
/// and need a runtime-state surface for the product enum.
|
|
202
|
+
/// Working → Busy
|
|
203
|
+
/// Idle → ProbablyIdle
|
|
204
|
+
/// Stuck → Unknown (CR rule: stale signals are never idle)
|
|
205
|
+
/// Uncertain → Unknown
|
|
206
|
+
pub fn from_activity(activity: ActivityStatus) -> Self {
|
|
207
|
+
match activity {
|
|
208
|
+
ActivityStatus::Working => Self::Busy,
|
|
209
|
+
ActivityStatus::Idle => Self::ProbablyIdle,
|
|
210
|
+
ActivityStatus::Stuck | ActivityStatus::Uncertain => Self::Unknown,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
129
215
|
/// 告警类型 (card §49;`scheduler.py:38` `_ALERT_TYPES`)。`stuck_cancel` 还接 `all` (展开全集)。
|
|
130
216
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
131
217
|
#[serde(rename_all = "snake_case")]
|
|
@@ -23,6 +23,71 @@ pub enum Provider {
|
|
|
23
23
|
Fake,
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/// 0.4.x Provider effort MVP: reasoning effort level passed to the provider.
|
|
27
|
+
/// Configuration sources (resolution order):
|
|
28
|
+
/// 1. role doc front matter `effort: low|medium|high|xhigh|max`
|
|
29
|
+
/// 2. TEAM.md front matter `provider_effort: low|medium|high|xhigh|max`
|
|
30
|
+
/// 3. provider default (framework passes no flag)
|
|
31
|
+
///
|
|
32
|
+
/// Provider support:
|
|
33
|
+
/// - claude / claude_code: low|medium|high|xhigh|max → `--effort <level>`
|
|
34
|
+
/// - codex: low|medium|high|xhigh (NOT max) → `-c model_reasoning_effort=<level>`
|
|
35
|
+
/// - copilot / gemini_cli / fake: unsupported — warning event, no flag
|
|
36
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
37
|
+
pub enum ProviderEffort {
|
|
38
|
+
#[serde(rename = "low")]
|
|
39
|
+
Low,
|
|
40
|
+
#[serde(rename = "medium")]
|
|
41
|
+
Medium,
|
|
42
|
+
#[serde(rename = "high")]
|
|
43
|
+
High,
|
|
44
|
+
#[serde(rename = "xhigh")]
|
|
45
|
+
XHigh,
|
|
46
|
+
#[serde(rename = "max")]
|
|
47
|
+
Max,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl ProviderEffort {
|
|
51
|
+
/// Parse from a wire string. Returns None on unknown literal.
|
|
52
|
+
pub fn parse(value: &str) -> Option<Self> {
|
|
53
|
+
match value.trim() {
|
|
54
|
+
"low" => Some(Self::Low),
|
|
55
|
+
"medium" => Some(Self::Medium),
|
|
56
|
+
"high" => Some(Self::High),
|
|
57
|
+
"xhigh" => Some(Self::XHigh),
|
|
58
|
+
"max" => Some(Self::Max),
|
|
59
|
+
_ => None,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Wire string used by both CLI argv and state serialization.
|
|
64
|
+
pub fn as_str(self) -> &'static str {
|
|
65
|
+
match self {
|
|
66
|
+
Self::Low => "low",
|
|
67
|
+
Self::Medium => "medium",
|
|
68
|
+
Self::High => "high",
|
|
69
|
+
Self::XHigh => "xhigh",
|
|
70
|
+
Self::Max => "max",
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Effort levels Claude-only (Codex / others must reject `max`).
|
|
75
|
+
pub fn is_claude_only(self) -> bool {
|
|
76
|
+
matches!(self, Self::Max)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// True when the given provider supports this effort level. `max` is
|
|
80
|
+
/// Claude-only; other levels are supported by Claude and Codex, ignored
|
|
81
|
+
/// by Copilot/Gemini/Fake (warning emitted at runtime).
|
|
82
|
+
pub fn is_supported_by(self, provider: Provider) -> bool {
|
|
83
|
+
match provider {
|
|
84
|
+
Provider::Claude | Provider::ClaudeCode => true,
|
|
85
|
+
Provider::Codex => !self.is_claude_only(),
|
|
86
|
+
Provider::Copilot | Provider::GeminiCli | Provider::Fake => false,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
26
91
|
/// auth 模式(`AUTH_MODES` `profiles/constants.py:6`)。
|
|
27
92
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
28
93
|
#[serde(rename_all = "snake_case")]
|
|
@@ -250,8 +250,19 @@ fn basic_schema_errors(spec: &Yaml) -> Vec<String> {
|
|
|
250
250
|
if !matches!(spec.get("version"), Some(Yaml::Int(1))) {
|
|
251
251
|
e.push("/version: must equal 1".to_string());
|
|
252
252
|
}
|
|
253
|
-
|
|
254
|
-
|
|
253
|
+
// 0.4.x provider effort MVP step 3: provider_effort is allowed but not required.
|
|
254
|
+
let team_required = &["name", "mode", "objective", "workspace"];
|
|
255
|
+
let team_allowed = &["name", "mode", "objective", "workspace", "provider_effort"];
|
|
256
|
+
check_keys_y(spec.get("team"), "/team", team_required, team_allowed, &mut e);
|
|
257
|
+
if let Some(team) = spec.get("team") {
|
|
258
|
+
if let Some(raw) = team.get("provider_effort").and_then(Yaml::as_str) {
|
|
259
|
+
if crate::model::enums::ProviderEffort::parse(raw).is_none() {
|
|
260
|
+
e.push(format!(
|
|
261
|
+
"/team/provider_effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
|
|
262
|
+
));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
255
266
|
let mode = spec.get("team").and_then(|t| t.get("mode")).and_then(Yaml::as_str);
|
|
256
267
|
if !matches!(mode, Some("supervisor_worker" | "swarm_limited")) {
|
|
257
268
|
e.push("/team/mode: invalid mode".to_string());
|
|
@@ -298,11 +309,32 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
|
|
|
298
309
|
"id", "role", "provider", "model", "working_directory", "system_prompt", "tools",
|
|
299
310
|
"permission_mode", "preferred_for", "avoid_for", "output_contract", "paused", "auth_mode",
|
|
300
311
|
"profile", "credential_ref", "forked_from",
|
|
312
|
+
// 0.4.x provider effort MVP step 3: per-agent effort override (resolved at compile).
|
|
313
|
+
"effort",
|
|
301
314
|
];
|
|
302
315
|
check_keys_y(Some(agent), path, req, allowed, errors);
|
|
303
316
|
if !agent.is_map() {
|
|
304
317
|
return;
|
|
305
318
|
}
|
|
319
|
+
// 0.4.x provider effort MVP step 3: effort syntactic + semantic validation.
|
|
320
|
+
if let Some(raw) = agent.get("effort").and_then(Yaml::as_str) {
|
|
321
|
+
match crate::model::enums::ProviderEffort::parse(raw) {
|
|
322
|
+
None => {
|
|
323
|
+
errors.push(format!(
|
|
324
|
+
"{path}/effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
|
|
325
|
+
));
|
|
326
|
+
}
|
|
327
|
+
Some(effort) if effort.is_claude_only() => {
|
|
328
|
+
let provider = agent.get("provider").and_then(Yaml::as_str).unwrap_or("");
|
|
329
|
+
if !matches!(provider, "claude" | "claude_code") {
|
|
330
|
+
errors.push(format!(
|
|
331
|
+
"{path}/effort: effort '{raw}' is only supported by claude/claude_code (provider: {provider})"
|
|
332
|
+
));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
Some(_) => {}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
306
338
|
check_keys_y(agent.get("system_prompt"), &format!("{path}/system_prompt"), &["inline", "file"], &["inline", "file"], errors);
|
|
307
339
|
check_list_y(agent.get("tools"), &format!("{path}/tools"), errors);
|
|
308
340
|
check_list_y(agent.get("preferred_for"), &format!("{path}/preferred_for"), errors);
|
|
@@ -128,3 +128,82 @@ fn read_and_remove(path: &std::path::Path) -> Vec<u8> {
|
|
|
128
128
|
let _ = std::fs::remove_file(path);
|
|
129
129
|
stdout
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
/// 0.4.x Phase 1 (fg-pgrp): terminal foreground process-group probe.
|
|
133
|
+
///
|
|
134
|
+
/// macOS probe confirmed (2026-06-29, .team/artifacts/fg-pgrp-probe.md):
|
|
135
|
+
/// `TIOCGPGRP` ioctl returns Errno 25 on tmux slave PTYs opened from a
|
|
136
|
+
/// non-controlling process. We use `ps -o tpgid,pgid -p <pid>` instead —
|
|
137
|
+
/// reliable on macOS AND Linux, no platform-specific cfg needed.
|
|
138
|
+
///
|
|
139
|
+
/// Returns `Ok(Some((tpgid, pgid)))` on success, `Ok(None)` when the PID
|
|
140
|
+
/// is gone / ps returned no row / values unparseable. The caller maps
|
|
141
|
+
/// `None` to `WorkerRuntimeState::Unknown` (never to idle — Iron Law).
|
|
142
|
+
///
|
|
143
|
+
/// `tpgid` = terminal foreground process group ID.
|
|
144
|
+
/// `pgid` = the agent root's own process group ID.
|
|
145
|
+
/// `tpgid != pgid` means a child process owns the foreground (BUSY signal).
|
|
146
|
+
///
|
|
147
|
+
/// Bounded via [`run_bounded_command`] so a wedged ps cannot stall the
|
|
148
|
+
/// coordinator tick.
|
|
149
|
+
pub(crate) fn pane_foreground_and_root_pgrp(pane_pid: u32) -> io::Result<Option<(u32, u32)>> {
|
|
150
|
+
let mut command = Command::new("ps");
|
|
151
|
+
command.args(["-o", "tpgid=,pgid=", "-p", &pane_pid.to_string()]);
|
|
152
|
+
let output = bounded_command_output_with_probe(
|
|
153
|
+
&mut command,
|
|
154
|
+
"pane_foreground_and_root_pgrp",
|
|
155
|
+
Some(pane_pid),
|
|
156
|
+
)?;
|
|
157
|
+
if probe_timed_out() {
|
|
158
|
+
return Ok(None);
|
|
159
|
+
}
|
|
160
|
+
if !output.status.success() {
|
|
161
|
+
return Ok(None);
|
|
162
|
+
}
|
|
163
|
+
let text = String::from_utf8_lossy(&output.stdout);
|
|
164
|
+
let first_line = text.lines().next().unwrap_or("").trim();
|
|
165
|
+
if first_line.is_empty() {
|
|
166
|
+
return Ok(None);
|
|
167
|
+
}
|
|
168
|
+
let parts: Vec<&str> = first_line.split_whitespace().collect();
|
|
169
|
+
if parts.len() < 2 {
|
|
170
|
+
return Ok(None);
|
|
171
|
+
}
|
|
172
|
+
let tpgid = parts[0].parse::<i64>().ok();
|
|
173
|
+
let pgid = parts[1].parse::<i64>().ok();
|
|
174
|
+
match (tpgid, pgid) {
|
|
175
|
+
// ps prints `-1` for tpgid when there is no controlling terminal —
|
|
176
|
+
// treat as unknown rather than fabricating a u32.
|
|
177
|
+
(Some(t), Some(p)) if t > 0 && p > 0 => Ok(Some((t as u32, p as u32))),
|
|
178
|
+
_ => Ok(None),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[cfg(test)]
|
|
183
|
+
mod fg_pgrp_tests {
|
|
184
|
+
use super::*;
|
|
185
|
+
|
|
186
|
+
#[test]
|
|
187
|
+
fn pane_foreground_and_root_pgrp_self_returns_some_or_none() {
|
|
188
|
+
// The current test process is not necessarily attached to a
|
|
189
|
+
// controlling terminal (CI runs detached), so we accept Some OR
|
|
190
|
+
// None. The contract is: never panic, return io::Result.
|
|
191
|
+
let pid = std::process::id();
|
|
192
|
+
match pane_foreground_and_root_pgrp(pid) {
|
|
193
|
+
Ok(Some((tpgid, pgid))) => {
|
|
194
|
+
assert!(tpgid > 0 && pgid > 0, "positive pgid/tpgid");
|
|
195
|
+
}
|
|
196
|
+
Ok(None) => {} // headless / no tty — acceptable
|
|
197
|
+
Err(e) => panic!("probe must not error in normal env: {e}"),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#[test]
|
|
202
|
+
fn pane_foreground_and_root_pgrp_missing_pid_returns_none() {
|
|
203
|
+
// PID 1 is init/launchd — exists but we read fields; non-existent
|
|
204
|
+
// PIDs return Ok(None) (ps exits non-zero).
|
|
205
|
+
let result =
|
|
206
|
+
pane_foreground_and_root_pgrp(0xFFFF_FFFE).expect("must not error on missing pid");
|
|
207
|
+
assert!(result.is_none(), "missing pid → None");
|
|
208
|
+
}
|
|
209
|
+
}
|