@team-agent/installer 0.4.9 → 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/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/lifecycle/launch.rs +155 -3
- package/crates/team-agent/src/lifecycle/profile_launch.rs +12 -0
- 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 +19 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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,
|
|
@@ -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));
|
|
@@ -450,6 +450,18 @@ pub(crate) fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BT
|
|
|
450
450
|
unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
|
|
451
451
|
unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
|
|
452
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());
|
|
453
465
|
if auth_mode == AuthMode::CompatibleApi {
|
|
454
466
|
unsets.insert("ANTHROPIC_API_KEY".to_string());
|
|
455
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
|
+
}
|
|
@@ -410,7 +410,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
410
410
|
Provider::Claude | Provider::ClaudeCode => {
|
|
411
411
|
Ok(claude_launch_command(self, auth_mode, mcp_config, system_prompt, model, tools)?)
|
|
412
412
|
}
|
|
413
|
-
Provider::Codex => Ok(codex_base_command(None, auth_mode, mcp_config, system_prompt, model, tools, None)),
|
|
413
|
+
Provider::Codex => Ok(codex_base_command(None, auth_mode, mcp_config, system_prompt, model, tools, None, None)),
|
|
414
414
|
// §C1 worker argv 形态 + C-1/C-5/C-6 cr verdict:
|
|
415
415
|
// copilot --no-color --no-auto-update [<dangerous|granular>] [--model]
|
|
416
416
|
// --additional-mcp-config <inline json> --session-id <expected_uuid> -C <cwd>
|
|
@@ -469,6 +469,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
469
469
|
model,
|
|
470
470
|
ctx.tools,
|
|
471
471
|
managed,
|
|
472
|
+
ctx.effort,
|
|
472
473
|
)?;
|
|
473
474
|
argv.push("--session-id".to_string());
|
|
474
475
|
argv.push(expected.clone());
|
|
@@ -507,6 +508,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
507
508
|
ctx.model,
|
|
508
509
|
ctx.tools,
|
|
509
510
|
ctx.profile_launch.map(|profile| &profile.command_overrides),
|
|
511
|
+
ctx.effort,
|
|
510
512
|
))),
|
|
511
513
|
// §C1 + §C4 cr verdict — copilot plan 端预定 UUID + workspace `-C` 双保险:
|
|
512
514
|
// * `--session-id <uuid>`(claude 同法,捕获免目录扫描,sqlite 仅校验)
|
|
@@ -638,13 +640,14 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
638
640
|
model,
|
|
639
641
|
tools,
|
|
640
642
|
None,
|
|
643
|
+
None,
|
|
641
644
|
);
|
|
642
645
|
argv.push(session_id.as_str().to_string());
|
|
643
646
|
Ok(argv)
|
|
644
647
|
}
|
|
645
648
|
Provider::Claude | Provider::ClaudeCode => {
|
|
646
649
|
let mut argv =
|
|
647
|
-
claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false)?;
|
|
650
|
+
claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false, None)?;
|
|
648
651
|
argv.push("--resume".to_string());
|
|
649
652
|
argv.push(session_id.as_str().to_string());
|
|
650
653
|
Ok(argv)
|
|
@@ -686,6 +689,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
686
689
|
model,
|
|
687
690
|
ctx.tools,
|
|
688
691
|
managed,
|
|
692
|
+
ctx.effort,
|
|
689
693
|
)?;
|
|
690
694
|
argv.push("--resume".to_string());
|
|
691
695
|
argv.push(session_id.as_str().to_string());
|
|
@@ -725,6 +729,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
725
729
|
ctx.model,
|
|
726
730
|
ctx.tools,
|
|
727
731
|
ctx.profile_launch.map(|profile| &profile.command_overrides),
|
|
732
|
+
ctx.effort,
|
|
728
733
|
);
|
|
729
734
|
argv.push(session_id.as_str().to_string());
|
|
730
735
|
Ok(CommandPlan::argv_only(argv))
|
|
@@ -779,13 +784,14 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
779
784
|
model,
|
|
780
785
|
tools,
|
|
781
786
|
None,
|
|
787
|
+
None,
|
|
782
788
|
);
|
|
783
789
|
argv.push(session_id.as_str().to_string());
|
|
784
790
|
Ok(argv)
|
|
785
791
|
}
|
|
786
792
|
Provider::Claude | Provider::ClaudeCode => {
|
|
787
793
|
let mut argv =
|
|
788
|
-
claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false)?;
|
|
794
|
+
claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false, None)?;
|
|
789
795
|
argv.push("--session-id".to_string());
|
|
790
796
|
argv.push(next_session_token());
|
|
791
797
|
argv.push("--resume".to_string());
|
|
@@ -837,6 +843,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
837
843
|
model,
|
|
838
844
|
ctx.tools,
|
|
839
845
|
managed,
|
|
846
|
+
ctx.effort,
|
|
840
847
|
)?;
|
|
841
848
|
argv.push("--session-id".to_string());
|
|
842
849
|
argv.push(expected.clone());
|
|
@@ -870,6 +877,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
870
877
|
ctx.model,
|
|
871
878
|
ctx.tools,
|
|
872
879
|
ctx.profile_launch.map(|profile| &profile.command_overrides),
|
|
880
|
+
ctx.effort,
|
|
873
881
|
);
|
|
874
882
|
argv.push(session_id.as_str().to_string());
|
|
875
883
|
Ok(CommandPlan::argv_only(argv))
|
|
@@ -34,6 +34,7 @@ pub(crate) fn claude_launch_command(
|
|
|
34
34
|
model,
|
|
35
35
|
tools,
|
|
36
36
|
false,
|
|
37
|
+
None,
|
|
37
38
|
)?;
|
|
38
39
|
argv.push("--session-id".to_string());
|
|
39
40
|
argv.push(next_session_token());
|
|
@@ -48,6 +49,9 @@ pub(crate) fn claude_base_command(
|
|
|
48
49
|
model: Option<&str>,
|
|
49
50
|
tools: &[&str],
|
|
50
51
|
managed_mcp_config: bool,
|
|
52
|
+
// 0.4.x provider effort MVP step 5: when Some, inject `--effort <level>`
|
|
53
|
+
// immediately after the model (before prompt/MCP).
|
|
54
|
+
effort: Option<crate::model::enums::ProviderEffort>,
|
|
51
55
|
) -> Result<Vec<String>, ProviderError> {
|
|
52
56
|
let mut argv = vec!["claude".to_string()];
|
|
53
57
|
if claude_dangerous_auto_approve(tools) {
|
|
@@ -60,6 +64,10 @@ pub(crate) fn claude_base_command(
|
|
|
60
64
|
argv.push("--model".to_string());
|
|
61
65
|
argv.push(model.to_string());
|
|
62
66
|
}
|
|
67
|
+
if let Some(effort) = effort {
|
|
68
|
+
argv.push("--effort".to_string());
|
|
69
|
+
argv.push(effort.as_str().to_string());
|
|
70
|
+
}
|
|
63
71
|
if let Some(prompt) = system_prompt {
|
|
64
72
|
argv.push("--append-system-prompt".to_string());
|
|
65
73
|
argv.push(prompt.to_string());
|
|
@@ -17,6 +17,12 @@ pub(crate) fn codex_base_command(
|
|
|
17
17
|
model: Option<&str>,
|
|
18
18
|
tools: &[&str],
|
|
19
19
|
overrides: Option<&ProviderCommandOverrides>,
|
|
20
|
+
// 0.4.x provider effort MVP step 6: when Some, inject
|
|
21
|
+
// `-c model_reasoning_effort=<level>` AFTER existing profile
|
|
22
|
+
// codex_config overrides — explicit launch effort wins over profile.
|
|
23
|
+
// Codex does not support `max`; the caller filters that case via
|
|
24
|
+
// `ProviderEffort::is_supported_by` before reaching this point.
|
|
25
|
+
effort: Option<crate::model::enums::ProviderEffort>,
|
|
20
26
|
) -> Vec<String> {
|
|
21
27
|
let mut argv = vec!["codex".to_string()];
|
|
22
28
|
if let Some(subcommand) = subcommand {
|
|
@@ -51,6 +57,10 @@ pub(crate) fn codex_base_command(
|
|
|
51
57
|
argv.push(config.clone());
|
|
52
58
|
}
|
|
53
59
|
}
|
|
60
|
+
if let Some(effort) = effort {
|
|
61
|
+
argv.push("-c".to_string());
|
|
62
|
+
argv.push(format!("model_reasoning_effort={}", effort.as_str()));
|
|
63
|
+
}
|
|
54
64
|
if let Some(prompt) = system_prompt {
|
|
55
65
|
// codex.py:120 — escape order matters: backslash first, then quote, then newline.
|
|
56
66
|
let escaped = prompt
|
|
@@ -347,6 +347,14 @@ pub struct ProviderCommandContext<'a> {
|
|
|
347
347
|
/// no `--name` CLI flag (probe verdict 2026-06-22) and ignores this
|
|
348
348
|
/// field. None = legacy callers (the field is purely additive).
|
|
349
349
|
pub agent_id_hint: Option<&'a str>,
|
|
350
|
+
/// 0.4.x provider effort MVP step 4: reasoning effort level resolved
|
|
351
|
+
/// from role doc / TEAM.md / provider default. `None` means the
|
|
352
|
+
/// framework passes no effort flag (provider default).
|
|
353
|
+
/// - Claude / ClaudeCode: low|medium|high|xhigh|max → `--effort <level>`
|
|
354
|
+
/// - Codex: low|medium|high|xhigh → `-c model_reasoning_effort=<level>`
|
|
355
|
+
/// - Copilot / Gemini / Fake: ignored, warning event emitted at the
|
|
356
|
+
/// caller (lifecycle/launch.rs / lifecycle/restart) before construct.
|
|
357
|
+
pub effort: Option<crate::model::enums::ProviderEffort>,
|
|
350
358
|
}
|
|
351
359
|
|
|
352
360
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
@@ -1321,6 +1321,8 @@ fn shell_command(
|
|
|
1321
1321
|
env: &BTreeMap<String, String>,
|
|
1322
1322
|
env_unset: &[String],
|
|
1323
1323
|
) -> String {
|
|
1324
|
+
let unset_set: std::collections::BTreeSet<&str> =
|
|
1325
|
+
env_unset.iter().map(String::as_str).collect();
|
|
1324
1326
|
let mut parts = Vec::new();
|
|
1325
1327
|
parts.push("cd".to_string());
|
|
1326
1328
|
parts.push(shell_quote(&cwd.to_string_lossy()));
|
|
@@ -1333,7 +1335,16 @@ fn shell_command(
|
|
|
1333
1335
|
parts.push(key.clone());
|
|
1334
1336
|
parts.push("&&".to_string());
|
|
1335
1337
|
}
|
|
1338
|
+
// 0.4.x ordering fix (env-leak symptom #3): KEY=val exports must NOT
|
|
1339
|
+
// re-introduce any key that was just unset. Filter env entries whose key
|
|
1340
|
+
// appears in env_unset so the unset wins on the final shell line. This
|
|
1341
|
+
// matters when inherited env (worker_spawn_env / apply_profile_launch_env)
|
|
1342
|
+
// contains the very keys we want to scrub (e.g. CLAUDE_EFFORT carried
|
|
1343
|
+
// forward from the launching shell into the env map).
|
|
1336
1344
|
for (key, value) in env {
|
|
1345
|
+
if unset_set.contains(key.as_str()) {
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1337
1348
|
parts.push(format!("{key}={}", shell_quote(value)));
|
|
1338
1349
|
}
|
|
1339
1350
|
parts.push("exec".to_string());
|
|
@@ -1378,6 +1389,8 @@ pub fn leader_shell_wrapper_command(
|
|
|
1378
1389
|
env_unset: &[String],
|
|
1379
1390
|
provider_label: &str,
|
|
1380
1391
|
) -> String {
|
|
1392
|
+
let unset_set: std::collections::BTreeSet<&str> =
|
|
1393
|
+
env_unset.iter().map(String::as_str).collect();
|
|
1381
1394
|
let mut parts = Vec::new();
|
|
1382
1395
|
// 1. cd
|
|
1383
1396
|
parts.push("cd".to_string());
|
|
@@ -1389,8 +1402,13 @@ pub fn leader_shell_wrapper_command(
|
|
|
1389
1402
|
parts.push(key.clone());
|
|
1390
1403
|
parts.push("&&".to_string());
|
|
1391
1404
|
}
|
|
1392
|
-
// 3. env exports + provider (NO `exec` so the provider is a child)
|
|
1405
|
+
// 3. env exports + provider (NO `exec` so the provider is a child).
|
|
1406
|
+
// 0.4.x ordering fix: skip keys present in env_unset so KEY=val does not
|
|
1407
|
+
// re-introduce a just-unset variable from the inherited env map.
|
|
1393
1408
|
for (key, value) in env {
|
|
1409
|
+
if unset_set.contains(key.as_str()) {
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1394
1412
|
parts.push(format!("{key}={}", shell_quote(value)));
|
|
1395
1413
|
}
|
|
1396
1414
|
parts.extend(argv.iter().map(|arg| shell_quote(arg)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.4.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.4.10",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.4.10",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.4.10"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|