@team-agent/installer 0.4.9 → 0.4.11
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/leader/start.rs +169 -25
- 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 +279 -10
- package/crates/team-agent/src/lifecycle/restart/common.rs +17 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +22 -13
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
- 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
|
@@ -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
|
}
|
|
@@ -420,6 +420,48 @@ fn agent_pane_live_by_id(
|
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
/// 0.4.10+ reset duplicate-window fix (CR-approved, plan §1).
|
|
424
|
+
///
|
|
425
|
+
/// Enumerate live panes whose `(session, window_name)` match the given pair.
|
|
426
|
+
/// Used by `stop_agent_at_paths` (when the stored pane_id is stale/dead but a
|
|
427
|
+
/// same-role window survives) and by the reset hard gate (to prove no
|
|
428
|
+
/// duplicate window residue remains before `start_agent_at_paths`).
|
|
429
|
+
///
|
|
430
|
+
/// `list_targets()` is a point-in-time tmux snapshot — duplicates ARE
|
|
431
|
+
/// preserved in the result so callers can see the full set.
|
|
432
|
+
///
|
|
433
|
+
/// Caller MUST also check `is_per_agent_window(window, agent_id)` before
|
|
434
|
+
/// using this list to kill panes (plan §2 safety constraint): a shared
|
|
435
|
+
/// layout window may host co-tenants.
|
|
436
|
+
fn list_same_role_panes(
|
|
437
|
+
transport: &dyn crate::transport::Transport,
|
|
438
|
+
session: &crate::transport::SessionName,
|
|
439
|
+
window: &str,
|
|
440
|
+
) -> Vec<crate::transport::PaneInfo> {
|
|
441
|
+
transport
|
|
442
|
+
.list_targets()
|
|
443
|
+
.unwrap_or_default()
|
|
444
|
+
.into_iter()
|
|
445
|
+
.filter(|pane| {
|
|
446
|
+
pane.session.as_str() == session.as_str()
|
|
447
|
+
&& pane.window_name.as_ref().map(WindowName::as_str) == Some(window)
|
|
448
|
+
})
|
|
449
|
+
.collect()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// 0.4.10+ reset duplicate-window fix (plan §2 safety constraint).
|
|
453
|
+
///
|
|
454
|
+
/// Returns true only when `window == agent_id`, i.e. the canonical
|
|
455
|
+
/// per-agent window. Adaptive/shared layout windows (`workers`, `team`,
|
|
456
|
+
/// or any layout window name produced by `is_adaptive_layout_window_pub`)
|
|
457
|
+
/// MUST NOT be broad-killed by name — they may host co-tenants. The
|
|
458
|
+
/// caller falls back to safer behavior (refuse stop, surface
|
|
459
|
+
/// RequirementUnmet/transport error) when this returns false.
|
|
460
|
+
fn is_per_agent_window(window: &str, agent_id: &AgentId) -> bool {
|
|
461
|
+
window == agent_id.as_str()
|
|
462
|
+
&& !crate::lifecycle::launch::is_adaptive_layout_window_pub(window)
|
|
463
|
+
}
|
|
464
|
+
|
|
423
465
|
fn tmux_start_mode_for_spawn(
|
|
424
466
|
spawn: &SpawnedAgentWindow,
|
|
425
467
|
into_existing_session: bool,
|
|
@@ -602,22 +644,62 @@ pub(super) fn stop_agent_at_paths(
|
|
|
602
644
|
.as_ref()
|
|
603
645
|
.map(|pane| pane.as_str().to_string())
|
|
604
646
|
.unwrap_or_else(|| format!("{}:{window}", session_name.as_str()));
|
|
605
|
-
|
|
647
|
+
// 0.4.10+ reset duplicate-window fix (plan §2): stop must resolve a
|
|
648
|
+
// STALE stored pane_id to live same-role panes by `(session, window)`
|
|
649
|
+
// enumeration BEFORE concluding the worker is absent. Pre-fix logic
|
|
650
|
+
// returned stopped=false when pane_id was dead even if a residual
|
|
651
|
+
// duplicate window survived — that residue then collided with
|
|
652
|
+
// reset's unconditional start, producing the observed
|
|
653
|
+
// `stopped=false, started=true` duplicate-window pattern.
|
|
654
|
+
//
|
|
655
|
+
// Only the STALE-pane-id branch enumerates same-role panes. When
|
|
656
|
+
// pane_id was never set in state (legacy state shape, never observed
|
|
657
|
+
// a real spawn), the existing window-based fallback is preserved —
|
|
658
|
+
// the duplicate-window bug requires a stored-but-stale pane_id as
|
|
659
|
+
// the trigger.
|
|
660
|
+
let stored_pane_live = pane_id
|
|
606
661
|
.as_ref()
|
|
607
662
|
.map(|pane| agent_pane_live_by_id(transport, pane))
|
|
608
|
-
.
|
|
663
|
+
.unwrap_or(false);
|
|
664
|
+
let stored_pane_stale = pane_id.is_some() && !stored_pane_live;
|
|
665
|
+
let same_role_panes: Vec<crate::transport::PaneInfo> =
|
|
666
|
+
if stored_pane_stale && is_per_agent_window(&window, agent_id) {
|
|
667
|
+
list_same_role_panes(transport, &session_name, &window)
|
|
668
|
+
} else {
|
|
669
|
+
Vec::new()
|
|
670
|
+
};
|
|
671
|
+
let stopped = stored_pane_live
|
|
672
|
+
|| !same_role_panes.is_empty()
|
|
673
|
+
|| (pane_id.is_none() && window_exists(transport, &session_name, &window));
|
|
609
674
|
if stopped {
|
|
610
675
|
// golden operations.py:84-86: a non-zero kill-window raises
|
|
611
676
|
// RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
677
|
+
//
|
|
678
|
+
// 0.4.10+ kill resolution order (plan §2):
|
|
679
|
+
// 1. stored pane_id is live → kill it by pane_id.
|
|
680
|
+
// 2. stored pane_id is stale BUT same-role panes survive →
|
|
681
|
+
// kill each by pane_id (duplicate window names make
|
|
682
|
+
// kill-window -t session:window ambiguous).
|
|
683
|
+
// 3. no pane_id at all but window exists → kill_window as
|
|
684
|
+
// before (legacy compat for state without pane_id field).
|
|
685
|
+
let kill_result: Result<(), crate::transport::TransportError> =
|
|
686
|
+
if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
|
|
687
|
+
transport.kill_pane(pane)
|
|
688
|
+
} else if !same_role_panes.is_empty() {
|
|
689
|
+
let mut last_err: Option<crate::transport::TransportError> = None;
|
|
690
|
+
for residual in &same_role_panes {
|
|
691
|
+
if let Err(e) = transport.kill_pane(&residual.pane_id) {
|
|
692
|
+
last_err = Some(e);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
last_err.map(Err).unwrap_or(Ok(()))
|
|
696
|
+
} else {
|
|
697
|
+
let target = Target::SessionWindow {
|
|
698
|
+
session: session_name.clone(),
|
|
699
|
+
window: WindowName::new(&window),
|
|
700
|
+
};
|
|
701
|
+
transport.kill_window(&target)
|
|
618
702
|
};
|
|
619
|
-
transport.kill_window(&target)
|
|
620
|
-
};
|
|
621
703
|
if let Err(e) = kill_result {
|
|
622
704
|
let stderr = match &e {
|
|
623
705
|
crate::transport::TransportError::Subprocess { stderr, .. } => stderr.trim().to_string(),
|
|
@@ -906,7 +988,158 @@ fn reset_agent_at_paths(
|
|
|
906
988
|
.filter(|session| !session.is_empty())
|
|
907
989
|
.map(SessionId::new);
|
|
908
990
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state_before_stop, Some(agent_id))?;
|
|
991
|
+
// Capture old pane_id / pane_pid / window BEFORE stop, so the hard gate
|
|
992
|
+
// below can prove the same prior instance is gone (or refuse start).
|
|
993
|
+
let old_pane_id_before = state_before_stop
|
|
994
|
+
.get("agents")
|
|
995
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
996
|
+
.and_then(|v| v.get("pane_id"))
|
|
997
|
+
.and_then(|v| v.as_str())
|
|
998
|
+
.filter(|p| !p.is_empty())
|
|
999
|
+
.map(crate::transport::PaneId::new);
|
|
1000
|
+
let old_pane_pid_before = state_pane_pid(&state_before_stop, agent_id);
|
|
1001
|
+
// CR C-2: take ONE pre-stop snapshot of the team session's panes so
|
|
1002
|
+
// the gate below can compute "what survived stop" by set difference,
|
|
1003
|
+
// not "what panes exist at all" (which would refuse legitimate
|
|
1004
|
+
// reset flows where stop killed the pane and the post-stop snapshot
|
|
1005
|
+
// includes that same pane id in a transport mock that does not
|
|
1006
|
+
// reflect kill removal).
|
|
1007
|
+
let pre_stop_window = state_before_stop
|
|
1008
|
+
.get("agents")
|
|
1009
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1010
|
+
.and_then(|v| v.get("window"))
|
|
1011
|
+
.and_then(|v| v.as_str())
|
|
1012
|
+
.filter(|s| !s.is_empty())
|
|
1013
|
+
.unwrap_or_else(|| agent_id.as_str())
|
|
1014
|
+
.to_string();
|
|
1015
|
+
let pre_stop_spec = load_team_spec(spec_workspace).ok();
|
|
1016
|
+
let pre_stop_session = pre_stop_spec
|
|
1017
|
+
.as_ref()
|
|
1018
|
+
.map(|spec| state_session_name_from_spec(&state_before_stop, spec));
|
|
1019
|
+
let pre_stop_pane_ids: std::collections::BTreeSet<String> =
|
|
1020
|
+
if let Some(session) = pre_stop_session.as_ref() {
|
|
1021
|
+
if is_per_agent_window(&pre_stop_window, agent_id) {
|
|
1022
|
+
list_same_role_panes(transport, session, &pre_stop_window)
|
|
1023
|
+
.iter()
|
|
1024
|
+
.map(|p| p.pane_id.as_str().to_string())
|
|
1025
|
+
.collect()
|
|
1026
|
+
} else {
|
|
1027
|
+
std::collections::BTreeSet::new()
|
|
1028
|
+
}
|
|
1029
|
+
} else {
|
|
1030
|
+
std::collections::BTreeSet::new()
|
|
1031
|
+
};
|
|
909
1032
|
let stop = stop_agent_at_paths(workspace, spec_workspace, agent_id, team, transport)?;
|
|
1033
|
+
|
|
1034
|
+
// 0.4.10+ paused agent skip: a paused agent's start path returns
|
|
1035
|
+
// StartAgentOutcome::Paused (no spawn). There is no duplicate-window
|
|
1036
|
+
// hazard, so the gate is a no-op for paused agents.
|
|
1037
|
+
let agent_is_paused = state_before_stop
|
|
1038
|
+
.get("agents")
|
|
1039
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1040
|
+
.and_then(|v| v.get("paused"))
|
|
1041
|
+
.and_then(|v| v.as_bool())
|
|
1042
|
+
.unwrap_or(false);
|
|
1043
|
+
// 0.4.10+ reset duplicate-window fix (plan §3): HARD GATE before start.
|
|
1044
|
+
// After stop_agent_at_paths returns, prove the old instance is gone OR
|
|
1045
|
+
// refuse to spawn. The pre-fix path unconditionally proceeded to
|
|
1046
|
+
// discard/save/start even when stop returned stopped=false (stop's
|
|
1047
|
+
// pane_id was stale), creating the observed duplicate-window pattern.
|
|
1048
|
+
//
|
|
1049
|
+
// Residue definition (correct: differential, not absolute):
|
|
1050
|
+
// A pane is RESIDUE iff it appears in BOTH the pre-stop snapshot
|
|
1051
|
+
// AND the post-stop snapshot. The pre-fix attempt used "any
|
|
1052
|
+
// matching pane exists post-stop" which broke legitimate flows
|
|
1053
|
+
// where the transport mock does not model kill_pane removal.
|
|
1054
|
+
//
|
|
1055
|
+
// Old pane id / pid checks:
|
|
1056
|
+
// The OLD stored pane_id / pane_pid must be gone (not just
|
|
1057
|
+
// reachable but actually killed). For real tmux this is the
|
|
1058
|
+
// structural truth source; for mocks the differential approach
|
|
1059
|
+
// above covers the post-stop visibility.
|
|
1060
|
+
//
|
|
1061
|
+
// CR C-5: gate is reset-specific; standalone stop-agent path keeps
|
|
1062
|
+
// existing "already absent is ok" behavior.
|
|
1063
|
+
//
|
|
1064
|
+
// Gate scope refinement: when stop.stopped == true, the kill_pane
|
|
1065
|
+
// call already succeeded (and drain_old_pane_and_pid polled for
|
|
1066
|
+
// the pane to become unreachable). Treat that as the authoritative
|
|
1067
|
+
// signal — running the gate again post-stop introduces a race
|
|
1068
|
+
// window where tmux's has_pane lag can spuriously report Live.
|
|
1069
|
+
// Only gate the dangerous case: stop reported stopped == false
|
|
1070
|
+
// (state's stale pane_id pointed at nothing kill-able), which is
|
|
1071
|
+
// exactly the duplicate-window bug pattern from the macmini
|
|
1072
|
+
// evidence: `stop_agent.complete stopped=false` followed by an
|
|
1073
|
+
// unconditional `start_agent.agent_start`.
|
|
1074
|
+
if !agent_is_paused && !stop.stopped {
|
|
1075
|
+
let spec_for_gate = load_team_spec(spec_workspace)?;
|
|
1076
|
+
let gate_state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
1077
|
+
let session_name_gate = state_session_name_from_spec(&gate_state, &spec_for_gate);
|
|
1078
|
+
let gate_window = gate_state
|
|
1079
|
+
.get("agents")
|
|
1080
|
+
.and_then(|v| v.get(agent_id.as_str()))
|
|
1081
|
+
.and_then(|v| v.get("window"))
|
|
1082
|
+
.and_then(|v| v.as_str())
|
|
1083
|
+
.filter(|s| !s.is_empty())
|
|
1084
|
+
.unwrap_or_else(|| agent_id.as_str())
|
|
1085
|
+
.to_string();
|
|
1086
|
+
let old_pane_still_live = old_pane_id_before
|
|
1087
|
+
.as_ref()
|
|
1088
|
+
.map(|p| agent_pane_live_by_id(transport, p))
|
|
1089
|
+
.unwrap_or(false);
|
|
1090
|
+
// Take a SECOND snapshot post-stop and compute the differential.
|
|
1091
|
+
// Only panes present in BOTH snapshots are residue (stop did not
|
|
1092
|
+
// remove them).
|
|
1093
|
+
let post_stop_panes: Vec<crate::transport::PaneInfo> =
|
|
1094
|
+
if is_per_agent_window(&gate_window, agent_id) {
|
|
1095
|
+
list_same_role_panes(transport, &session_name_gate, &gate_window)
|
|
1096
|
+
} else {
|
|
1097
|
+
Vec::new()
|
|
1098
|
+
};
|
|
1099
|
+
let remaining_panes: Vec<crate::transport::PaneInfo> = post_stop_panes
|
|
1100
|
+
.into_iter()
|
|
1101
|
+
.filter(|p| pre_stop_pane_ids.contains(p.pane_id.as_str()))
|
|
1102
|
+
.collect();
|
|
1103
|
+
// Pid-alone aliveness is secondary evidence and noisy under fixtures
|
|
1104
|
+
// (synthetic pids may by chance be live on the test machine). Block
|
|
1105
|
+
// ONLY on tmux-visible residue: old pane still live OR same-role
|
|
1106
|
+
// panes survived stop. The pid is still recorded in the event for
|
|
1107
|
+
// diagnostics.
|
|
1108
|
+
let old_pid_still_live = old_pane_pid_before
|
|
1109
|
+
.filter(|_| old_pane_still_live)
|
|
1110
|
+
.map(|pid| pid_is_alive(pid))
|
|
1111
|
+
.unwrap_or(false);
|
|
1112
|
+
if old_pane_still_live || !remaining_panes.is_empty() {
|
|
1113
|
+
let remaining_pane_ids: Vec<String> = remaining_panes
|
|
1114
|
+
.iter()
|
|
1115
|
+
.map(|p| p.pane_id.as_str().to_string())
|
|
1116
|
+
.collect();
|
|
1117
|
+
let old_pane_str = old_pane_id_before
|
|
1118
|
+
.as_ref()
|
|
1119
|
+
.map(|p| p.as_str().to_string())
|
|
1120
|
+
.unwrap_or_default();
|
|
1121
|
+
let old_pid_val = old_pane_pid_before.unwrap_or(0);
|
|
1122
|
+
let _ = write_reset_stop_not_proven_event(
|
|
1123
|
+
workspace,
|
|
1124
|
+
agent_id,
|
|
1125
|
+
&old_pane_str,
|
|
1126
|
+
old_pid_val,
|
|
1127
|
+
&remaining_pane_ids,
|
|
1128
|
+
);
|
|
1129
|
+
// CR C-1 N38 three-line error: error / action / log_hint.
|
|
1130
|
+
let _ = stop; // silence unused on the refusal path
|
|
1131
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
1132
|
+
"reset refused: old agent instance still live for {agent_id}\n\
|
|
1133
|
+
action: stop the worker manually with `team-agent stop-agent {agent_id} --team <team>` then retry reset, or kill the residual tmux panes [{ids}]\n\
|
|
1134
|
+
log_hint: see reset_agent.stop_not_proven event (old_pane_id={old}, old_pane_pid={pid}, remaining_panes=[{ids}])",
|
|
1135
|
+
agent_id = agent_id.as_str(),
|
|
1136
|
+
ids = remaining_pane_ids.join(", "),
|
|
1137
|
+
old = old_pane_str,
|
|
1138
|
+
pid = old_pid_val,
|
|
1139
|
+
)));
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
910
1143
|
let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
911
1144
|
let spec = load_team_spec(spec_workspace)?;
|
|
912
1145
|
discard_agent_session_fields(&mut state, agent_id)?;
|
|
@@ -1058,6 +1291,15 @@ fn write_start_agent_start_event(
|
|
|
1058
1291
|
.model
|
|
1059
1292
|
.as_deref()
|
|
1060
1293
|
.or(model);
|
|
1294
|
+
// 0.4.x provider effort MVP: start_agent path preserves effort from the
|
|
1295
|
+
// persisted agent JSON.
|
|
1296
|
+
let start_agent_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(&agent, provider);
|
|
1297
|
+
if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
|
|
1298
|
+
&agent, provider, agent_id.as_str(),
|
|
1299
|
+
) {
|
|
1300
|
+
let _ = crate::event_log::EventLog::new(workspace)
|
|
1301
|
+
.write("provider.effort_unsupported", event_value);
|
|
1302
|
+
}
|
|
1061
1303
|
let context = crate::provider::ProviderCommandContext {
|
|
1062
1304
|
auth_mode,
|
|
1063
1305
|
mcp_config: Some(&mcp_config),
|
|
@@ -1066,6 +1308,7 @@ fn write_start_agent_start_event(
|
|
|
1066
1308
|
tools: &resolved_tool_refs,
|
|
1067
1309
|
profile_launch: Some(&profile_launch),
|
|
1068
1310
|
agent_id_hint: Some(agent_id.as_str()),
|
|
1311
|
+
effort: start_agent_effort,
|
|
1069
1312
|
};
|
|
1070
1313
|
let mut plan = match session_id {
|
|
1071
1314
|
Some(session_id) => adapter
|
|
@@ -1188,6 +1431,32 @@ fn write_reset_tombstone_event(
|
|
|
1188
1431
|
Ok(())
|
|
1189
1432
|
}
|
|
1190
1433
|
|
|
1434
|
+
/// 0.4.10+ reset duplicate-window fix (plan §3): emit a structured event
|
|
1435
|
+
/// when the reset hard gate refuses to start because the old instance is
|
|
1436
|
+
/// proven still live (old pane id reachable, old pane pid alive, or
|
|
1437
|
+
/// same-role panes remain in the team session).
|
|
1438
|
+
fn write_reset_stop_not_proven_event(
|
|
1439
|
+
workspace: &Path,
|
|
1440
|
+
agent_id: &AgentId,
|
|
1441
|
+
old_pane_id: &str,
|
|
1442
|
+
old_pane_pid: u32,
|
|
1443
|
+
remaining_panes: &[String],
|
|
1444
|
+
) -> Result<(), LifecycleError> {
|
|
1445
|
+
crate::event_log::EventLog::new(workspace)
|
|
1446
|
+
.write(
|
|
1447
|
+
"reset_agent.stop_not_proven",
|
|
1448
|
+
serde_json::json!({
|
|
1449
|
+
"agent_id": agent_id.as_str(),
|
|
1450
|
+
"old_pane_id": old_pane_id,
|
|
1451
|
+
"old_pane_pid": old_pane_pid,
|
|
1452
|
+
"remaining_panes": remaining_panes,
|
|
1453
|
+
"action": "stop the worker manually then retry reset, or kill the residual tmux panes",
|
|
1454
|
+
}),
|
|
1455
|
+
)
|
|
1456
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
1457
|
+
Ok(())
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1191
1460
|
fn write_reset_complete_event(
|
|
1192
1461
|
workspace: &Path,
|
|
1193
1462
|
agent_id: &AgentId,
|
|
@@ -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)
|