@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 CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.9"
569
+ version = "0.4.11"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.4.9"
12
+ version = "0.4.11"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -743,7 +743,10 @@ use rusqlite::params;
743
743
  return json!({});
744
744
  };
745
745
  let mut out = Map::new();
746
- for key in ["status", "provider", "activity", "last_output_at"] {
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,
@@ -61,12 +61,20 @@ pub fn leader_start_plan(
61
61
  // `managed_team_session_name(identity) = team-<team_id>` which is the
62
62
  // worker session — that co-location is the structural root of
63
63
  // E49/E51/E53/E57-3/E60.
64
+ // 0.4.10+ mirror-session fix v2 (option B): managed-mode session name
65
+ // gets a per-launch nonce so each `team-agent <provider>` entry in the
66
+ // same workspace creates its OWN tmux session — matching the user
67
+ // expectation that `tmux new-session + claude` is independent every
68
+ // time. Pre-fix the managed path used the workspace-keyed name (same
69
+ // session for every entry) and silently attached the second client →
70
+ // UI mirror. The external/attach paths keep the stable workspace-keyed
71
+ // name (per-launch nonce would break `--attach-session` reattach).
64
72
  let session_name = if external_path {
65
73
  attach_session
66
74
  .cloned()
67
75
  .or_else(|| Some(leader_session_name(provider, workspace)))
68
76
  } else {
69
- Some(leader_session_name(provider, workspace))
77
+ Some(managed_leader_session_name(provider, workspace))
70
78
  };
71
79
  let in_tmux = std::env::var_os("TMUX").is_some();
72
80
  if !in_tmux {
@@ -266,6 +274,42 @@ pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName
266
274
  ))
267
275
  }
268
276
 
277
+ /// 0.4.10+ mirror-session fix v2: managed-mode session name with a
278
+ /// per-launch nonce.
279
+ ///
280
+ /// Format: `team-agent-leader-<provider>-<folder>-<hash>-<nonce>`.
281
+ /// `nonce` = `<pid_hex>-<epoch_nanos_hex>`. The pid distinguishes
282
+ /// concurrent launches; the epoch_nanos distinguishes sequential ones.
283
+ ///
284
+ /// Each `team-agent <provider>` entry in the managed (non-tmux) path gets
285
+ /// its OWN session, matching the user expectation that `tmux new-session
286
+ /// + claude` is independent every time. The workspace-keyed prefix is
287
+ /// preserved so existing leader-session-anchored protections
288
+ /// (`LEADER_SESSION_PREFIX` matchers in shutdown/cli/mod.rs) still
289
+ /// classify these sessions as leader sessions.
290
+ ///
291
+ /// External / attach paths keep the stable `leader_session_name` —
292
+ /// per-launch nonce would break `--attach-session <name>` reattach
293
+ /// semantics.
294
+ pub fn managed_leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
295
+ let resolved = resolve_workspace_for_hash(workspace);
296
+ let folder_raw = resolved
297
+ .file_name()
298
+ .and_then(|n| n.to_str())
299
+ .unwrap_or("workspace");
300
+ let folder = sanitize_session_folder(folder_raw);
301
+ let hash = sha1_hex_prefix(resolved.to_string_lossy().as_bytes(), 8);
302
+ let pid = std::process::id();
303
+ let epoch_nanos = std::time::SystemTime::now()
304
+ .duration_since(std::time::UNIX_EPOCH)
305
+ .map(|d| d.as_nanos())
306
+ .unwrap_or(0);
307
+ SessionName::new(format!(
308
+ "{LEADER_SESSION_PREFIX}{}-{folder}-{hash}-{pid:x}-{epoch_nanos:x}",
309
+ provider_wire(provider)
310
+ ))
311
+ }
312
+
269
313
  fn start_argv(
270
314
  mode: LeaderStartMode,
271
315
  provider: Provider,
@@ -423,31 +467,14 @@ fn ensure_managed_leader_pane(
423
467
  plan: &LeaderStartPlan,
424
468
  workspace: &Path,
425
469
  ) -> Result<SpawnResult, LeaderError> {
470
+ // 0.4.10+ mirror-session fix v2 (option B): each managed launch gets
471
+ // its OWN session name (via `managed_leader_session_name`'s nonce).
472
+ // The historical "session already exists → reuse pane" branch is
473
+ // structurally unreachable now (the per-launch session never
474
+ // preexists). If a caller bypasses the nonce path and reuses a name,
475
+ // tmux's own duplicate-session error will surface — better than
476
+ // silently attaching a second client.
426
477
  if transport.has_session(session).unwrap_or(false) {
427
- if transport
428
- .list_windows(session)
429
- .unwrap_or_default()
430
- .iter()
431
- .any(|existing| existing.as_str() == window.as_str())
432
- {
433
- if let Some(existing) = transport
434
- .list_targets()
435
- .unwrap_or_default()
436
- .into_iter()
437
- .find(|pane| {
438
- pane.session.as_str() == session.as_str()
439
- && pane.window_name.as_ref().map(WindowName::as_str)
440
- == Some(window.as_str())
441
- })
442
- {
443
- return Ok(SpawnResult {
444
- pane_id: existing.pane_id,
445
- session: session.clone(),
446
- window: window.clone(),
447
- child_pid: existing.pane_pid,
448
- });
449
- }
450
- }
451
478
  // 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
452
479
  // provider_env_unsets (single source of truth) + spawn through the
453
480
  // leader shell wrapper so provider exit returns to a shell, not
@@ -1586,4 +1613,121 @@ mod tests {
1586
1613
  assert_eq!(sent[0].0, Target::Pane(PaneId::new("%0")));
1587
1614
  assert_eq!(sent[0].1, vec![Key::Enter]);
1588
1615
  }
1616
+
1617
+ // ═══════════════════════════════════════════════════════════════════
1618
+ // 0.4.10+ mirror-session fix v2 (option B: independent session per launch).
1619
+ //
1620
+ // managed_leader_session_name appends a per-launch nonce so each
1621
+ // `team-agent <provider>` entry in the same workspace creates its own
1622
+ // tmux session, matching `tmux new-session + claude` semantics.
1623
+ // ═══════════════════════════════════════════════════════════════════
1624
+
1625
+ #[test]
1626
+ fn managed_leader_session_name_carries_leader_prefix() {
1627
+ // Protection invariant: every session name returned by the
1628
+ // managed path must still match LEADER_SESSION_PREFIX so the
1629
+ // shutdown / cli/mod.rs prefix matchers still classify it as a
1630
+ // leader session.
1631
+ let workspace = std::env::temp_dir().join(format!(
1632
+ "ta_rs_mgr_prefix_{}",
1633
+ std::process::id()
1634
+ ));
1635
+ let _ = std::fs::create_dir_all(&workspace);
1636
+ let name = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
1637
+ assert!(
1638
+ name.as_str().starts_with(super::super::LEADER_SESSION_PREFIX),
1639
+ "managed session name must carry LEADER_SESSION_PREFIX (`{}`); \
1640
+ got `{}`",
1641
+ super::super::LEADER_SESSION_PREFIX,
1642
+ name.as_str()
1643
+ );
1644
+ assert!(
1645
+ name.as_str().contains("claude_code"),
1646
+ "managed session name must include provider wire; got `{}`",
1647
+ name.as_str()
1648
+ );
1649
+ let _ = std::fs::remove_dir_all(&workspace);
1650
+ }
1651
+
1652
+ #[test]
1653
+ fn managed_leader_session_name_is_unique_per_call() {
1654
+ // Two consecutive calls must return DIFFERENT names — the
1655
+ // per-launch nonce defeats the mirror-session bug.
1656
+ let workspace = std::env::temp_dir().join(format!(
1657
+ "ta_rs_mgr_unique_{}",
1658
+ std::process::id()
1659
+ ));
1660
+ let _ = std::fs::create_dir_all(&workspace);
1661
+ let a = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
1662
+ // Sleep > 1ns to ensure epoch_nanos advances even on coarse clocks.
1663
+ std::thread::sleep(std::time::Duration::from_millis(1));
1664
+ let b = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
1665
+ assert_ne!(
1666
+ a.as_str(),
1667
+ b.as_str(),
1668
+ "two managed launches in the same workspace must get \
1669
+ DIFFERENT session names (mirror-session fix v2); got \
1670
+ `{}` vs `{}`",
1671
+ a.as_str(),
1672
+ b.as_str()
1673
+ );
1674
+ let _ = std::fs::remove_dir_all(&workspace);
1675
+ }
1676
+
1677
+ #[test]
1678
+ fn leader_session_name_is_stable_across_calls() {
1679
+ // External / attach paths must keep the stable workspace-keyed
1680
+ // name — `--attach-session <name>` reattach would break if the
1681
+ // name varied per call.
1682
+ let workspace = std::env::temp_dir().join(format!(
1683
+ "ta_rs_lsn_stable_{}",
1684
+ std::process::id()
1685
+ ));
1686
+ let _ = std::fs::create_dir_all(&workspace);
1687
+ let a = super::leader_session_name(Provider::ClaudeCode, &workspace);
1688
+ let b = super::leader_session_name(Provider::ClaudeCode, &workspace);
1689
+ assert_eq!(
1690
+ a.as_str(),
1691
+ b.as_str(),
1692
+ "leader_session_name must be deterministic per workspace \
1693
+ (external/attach paths depend on stability); got `{}` vs `{}`",
1694
+ a.as_str(),
1695
+ b.as_str()
1696
+ );
1697
+ let _ = std::fs::remove_dir_all(&workspace);
1698
+ }
1699
+
1700
+ /// CR C-5 source grep guard: managed mode passes the nonce session
1701
+ /// name through `leader_start_plan` so the per-launch independence
1702
+ /// reaches `ensure_managed_leader_pane`. External/attach paths must
1703
+ /// keep `leader_session_name`.
1704
+ #[test]
1705
+ fn managed_path_uses_nonce_session_name_grep_guard() {
1706
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1707
+ let start_rs = manifest.join("src").join("leader").join("start.rs");
1708
+ let contents =
1709
+ std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
1710
+ // leader_start_plan must dispatch to managed_leader_session_name
1711
+ // on the managed path.
1712
+ let start = contents
1713
+ .find("pub fn leader_start_plan(")
1714
+ .expect("leader_start_plan must exist");
1715
+ let end = contents[start + 1..]
1716
+ .find("\npub fn ")
1717
+ .or_else(|| contents[start + 1..].find("\nfn "))
1718
+ .map(|p| start + 1 + p)
1719
+ .unwrap_or(contents.len());
1720
+ let body = &contents[start..end];
1721
+ assert!(
1722
+ body.contains("managed_leader_session_name(provider, workspace)"),
1723
+ "leader_start_plan must call managed_leader_session_name on \
1724
+ the managed path (mirror-session fix v2); body excerpt: {body}"
1725
+ );
1726
+ // The external_path branch must still use the stable name.
1727
+ assert!(
1728
+ body.contains("leader_session_name(provider, workspace)"),
1729
+ "leader_start_plan must keep leader_session_name on the \
1730
+ external/attach paths; body excerpt: {body}"
1731
+ );
1732
+ }
1589
1733
  }