@team-agent/installer 0.3.39 → 0.4.0

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.
Files changed (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. package/package.json +4 -4
@@ -0,0 +1,187 @@
1
+ //! unit-0 (Stage 0) characterization tests for restart resume preflight.
2
+ //!
3
+ //! These pin the current `classify_restart_plan` behavior so that the
4
+ //! Stage 2 refactor (unit-5: replace the opaque `session_unresumable`
5
+ //! string with a `ResumeRefusalReason` enum + structured backing-store
6
+ //! check) is detectable as a behavior change rather than an accidental
7
+ //! regression.
8
+ //!
9
+ //! Pinned invariants:
10
+ //! - A worker with `session_id` set but backing store absent is currently
11
+ //! tagged with the OPAQUE string `session_unresumable`. unit-5 must
12
+ //! evolve this to a structured reason (e.g. `session_backing_store_missing`).
13
+ //! - A worker with NO `session_id` is currently tagged
14
+ //! `no_persisted_session_id` — distinct from the missing-backing case.
15
+ //! unit-5 must keep this distinction.
16
+ //! - `allow_fresh=true` converts both refusal classes into a `FreshStart`
17
+ //! decision (no entries in `unresumable`).
18
+
19
+ use super::*;
20
+ use crate::lifecycle::restart::classify_restart_plan;
21
+ use crate::lifecycle::types::{ResumeDecision, StartMode};
22
+
23
+ fn agent_codex(session_id: Option<&str>, first_send_at: serde_json::Value) -> serde_json::Value {
24
+ let mut obj = serde_json::Map::new();
25
+ obj.insert("provider".to_string(), json!("codex"));
26
+ obj.insert("status".to_string(), json!("running"));
27
+ obj.insert("first_send_at".to_string(), first_send_at);
28
+ if let Some(sid) = session_id {
29
+ obj.insert("session_id".to_string(), json!(sid));
30
+ }
31
+ serde_json::Value::Object(obj)
32
+ }
33
+
34
+ #[test]
35
+ fn unit0_restart_missing_session_id_is_no_persisted_session_id() {
36
+ let state = json!({
37
+ "agents": {
38
+ "a": agent_codex(None, json!("2026-01-01T00:00:00Z")),
39
+ }
40
+ });
41
+ let plan = classify_restart_plan(&state, false).unwrap();
42
+ assert_eq!(plan.unresumable.len(), 1);
43
+ assert_eq!(plan.unresumable[0].agent_id.as_str(), "a");
44
+ assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
45
+ assert!(plan.unresumable[0].session_id.is_none());
46
+ }
47
+
48
+ #[test]
49
+ fn unit0_restart_session_id_present_no_backing_is_session_unresumable() {
50
+ // session_id is set, but resume_backing_exists_for_agent will return
51
+ // false because no workspace was passed AND rollout_path is missing.
52
+ // The current behavior is to flatten this into `session_unresumable`
53
+ // — the opaque reason that unit-5 will replace with structured enum.
54
+ let state = json!({
55
+ "agents": {
56
+ "a": agent_codex(Some("sess-unit0-missing"), json!("2026-01-01T00:00:00Z")),
57
+ }
58
+ });
59
+ let plan = classify_restart_plan(&state, false).unwrap();
60
+ assert_eq!(plan.unresumable.len(), 1);
61
+ assert_eq!(plan.unresumable[0].agent_id.as_str(), "a");
62
+ assert_eq!(plan.unresumable[0].reason, "session_unresumable");
63
+ assert!(plan.unresumable[0].session_id.is_some());
64
+ }
65
+
66
+ #[test]
67
+ fn unit0_restart_allow_fresh_converts_refusals_into_fresh_decisions() {
68
+ let state = json!({
69
+ "agents": {
70
+ "a": agent_codex(None, json!("2026-01-01T00:00:00Z")),
71
+ "b": agent_codex(Some("sess-unit0-allow-fresh"), json!("2026-01-01T00:00:00Z")),
72
+ }
73
+ });
74
+ let plan = classify_restart_plan(&state, true).unwrap();
75
+ // Both unresumable-class workers should be turned into FreshStart.
76
+ assert!(
77
+ plan.unresumable.is_empty(),
78
+ "allow_fresh should drain the unresumable bucket; got {:?}",
79
+ plan.unresumable
80
+ );
81
+ assert_eq!(plan.decisions.len(), 2);
82
+ for d in &plan.decisions {
83
+ assert!(
84
+ matches!(d.restart_mode, StartMode::Fresh),
85
+ "agent {} should be Fresh under allow_fresh; got {:?}",
86
+ d.agent_id.as_str(),
87
+ d.restart_mode
88
+ );
89
+ }
90
+ let _ = ResumeDecision::FreshStart; // touch the enum so unused-import lint stays quiet.
91
+ }
92
+
93
+ #[test]
94
+ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
95
+ // Leader follow-up 2026-06-22: when classify_restart_plan_with_resume_validation
96
+ // runs with a real workspace, a codex worker whose session_id is set
97
+ // but whose rollout_path does not exist on disk should produce a
98
+ // refusal whose structured ResumeRefusalReason::SessionBackingStoreMissing
99
+ // carries the actual probed paths (the persisted rollout_path) AND a
100
+ // RecoveryHint with the agent_id as the picker name.
101
+ use std::sync::atomic::{AtomicU32, Ordering};
102
+ static N: AtomicU32 = AtomicU32::new(0);
103
+ let n = N.fetch_add(1, Ordering::Relaxed);
104
+ let ws = std::env::temp_dir().join(format!(
105
+ "ta_rs_l2_backingmiss_{}_{}",
106
+ std::process::id(),
107
+ n
108
+ ));
109
+ std::fs::create_dir_all(&ws).unwrap();
110
+
111
+ let missing_rollout = ws.join(".missing-rollout.jsonl");
112
+ // Do NOT create the file. Probe must report it as not-existing AND
113
+ // include it in checked_paths.
114
+ let mut agent = serde_json::Map::new();
115
+ agent.insert("provider".to_string(), json!("codex"));
116
+ agent.insert("status".to_string(), json!("running"));
117
+ agent.insert("session_id".to_string(), json!("sess-layer2-missing"));
118
+ agent.insert(
119
+ "rollout_path".to_string(),
120
+ json!(missing_rollout.to_string_lossy()),
121
+ );
122
+ agent.insert("first_send_at".to_string(), json!("2026-01-01T00:00:00Z"));
123
+ agent.insert("spawn_cwd".to_string(), json!(ws.to_string_lossy()));
124
+ let state = json!({ "agents": { "a": serde_json::Value::Object(agent) } });
125
+
126
+ let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
127
+ Some(&ws),
128
+ &state,
129
+ false,
130
+ )
131
+ .unwrap();
132
+ assert_eq!(plan.unresumable.len(), 1);
133
+ let entry = &plan.unresumable[0];
134
+ assert_eq!(entry.agent_id.as_str(), "a");
135
+ assert_eq!(entry.reason, "session_unresumable");
136
+ match entry.refusal_reason.as_ref() {
137
+ Some(crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
138
+ checked_paths,
139
+ recovery_hint,
140
+ }) => {
141
+ assert!(
142
+ !checked_paths.is_empty(),
143
+ "checked_paths must be populated; got empty"
144
+ );
145
+ assert!(
146
+ checked_paths.iter().any(|p| p == &missing_rollout),
147
+ "checked_paths must include the persisted rollout_path; got {checked_paths:?}"
148
+ );
149
+ let hint = recovery_hint
150
+ .as_ref()
151
+ .expect("recovery_hint should be populated");
152
+ assert_eq!(
153
+ hint.provider_session_name_hint.as_deref(),
154
+ Some("a"),
155
+ "name hint must equal agent_id"
156
+ );
157
+ assert_eq!(
158
+ hint.spawn_cwd.as_deref(),
159
+ Some(ws.as_path()),
160
+ "spawn_cwd should round-trip through the hint"
161
+ );
162
+ assert_eq!(hint.provider, "codex");
163
+ }
164
+ other => panic!(
165
+ "expected SessionBackingStoreMissing with structured fields; got {:?}",
166
+ other
167
+ ),
168
+ }
169
+ }
170
+
171
+ #[test]
172
+ fn unit0_restart_corrupt_first_send_at_blocks_before_resume_classification() {
173
+ // The corrupt-first_send_at branch is the hard-refuse gate that fires
174
+ // BEFORE resume classification. This pins that corrupt entries land in
175
+ // `corrupt_entries` (with python type name) and the unresumable bucket
176
+ // stays empty for the corrupt agent.
177
+ let state = json!({
178
+ "agents": {
179
+ "a": agent_codex(Some("sess-unit0-corrupt"), json!(false)),
180
+ }
181
+ });
182
+ let plan = classify_restart_plan(&state, false).unwrap();
183
+ assert_eq!(plan.corrupt_entries.len(), 1);
184
+ assert_eq!(plan.corrupt_entries[0].worker_id.as_str(), "a");
185
+ assert_eq!(plan.corrupt_entries[0].raw_first_send_at_type, "bool");
186
+ assert!(plan.unresumable.is_empty());
187
+ }
@@ -25,3 +25,4 @@ mod launch_spawn;
25
25
  mod agent_ops;
26
26
  mod lane_ops;
27
27
  mod main_preserved;
28
+ mod restart;
@@ -667,6 +667,18 @@ pub enum RestartReport {
667
667
  allow_fresh: bool,
668
668
  error: String,
669
669
  },
670
+ /// unit-3 (Stage 1): session-identity preflight refused — `state.session_name`
671
+ /// is a leader launcher session (`team-agent-leader-*`). Proceeding would
672
+ /// tear down the leader pane (E49 / 0.3.39). **nothing created or killed**.
673
+ RefusedDirtyTopology {
674
+ /// The session name that failed the preflight, verbatim from state.
675
+ session_name: String,
676
+ /// Stable machine-readable reason (currently
677
+ /// `"worker_session_is_leader_session"`).
678
+ reason: String,
679
+ /// Human-readable error directing the user to a recovery action.
680
+ error: String,
681
+ },
670
682
  }
671
683
 
672
684
  /// 单个重建后的 worker(carry restart_mode)。
@@ -690,11 +702,18 @@ pub struct RestartFailedAgent {
690
702
  }
691
703
 
692
704
  /// atomic refusal 里的不可重建 worker(`orchestration.py:517`)。
705
+ ///
706
+ /// unit-5 (Stage 2): `reason` 是历史 free-form 字符串,JSON/CLI 直接吃它;新增
707
+ /// `refusal_reason` 携带 unit-5 的 closed enum(`provider::session::ResumeRefusalReason`)
708
+ /// 同源 wire 字符串。两者必须 round-trip 一致(see `ResumeRefusalReason::wire`)。
709
+ /// 旧调用方读 `reason` 不变;新调用方走 enum 拿到结构化原因。
693
710
  #[derive(Debug, Clone, PartialEq, Eq)]
694
711
  pub struct UnresumableWorker {
695
712
  pub agent_id: AgentId,
696
- /// `no_persisted_session_id` | `session_unresumable`。
713
+ /// `no_persisted_session_id` | `session_unresumable` | `session_backing_store_missing` | …
697
714
  pub reason: String,
715
+ /// unit-5 structured refusal (optional during migration; populated alongside `reason`).
716
+ pub refusal_reason: Option<crate::provider::session::ResumeRefusalReason>,
698
717
  pub session_id: Option<SessionId>,
699
718
  pub first_send_at: Option<String>,
700
719
  }
@@ -221,9 +221,36 @@ pub(crate) fn delivery_outcome_value(out: &DeliveryOutcome) -> Value {
221
221
  value
222
222
  }
223
223
 
224
- pub(crate) fn latest_task_for_assignee(workspace: &Path, agent_id: &str) -> Option<String> {
224
+ /// Find the latest nonterminal task assigned to `agent_id`.
225
+ ///
226
+ /// Blocker-1 (prerelease 0.4.0): scoped teams own their tasks under
227
+ /// `state.teams.<owner>.tasks`; top-level `state.tasks` is the legacy /
228
+ /// fallback list and is often stale or assigned to a different agent in
229
+ /// scoped-team workspaces. Search the scoped view first (when an owner team
230
+ /// is supplied), then fall back to top-level.
231
+ pub(crate) fn latest_task_for_assignee(
232
+ workspace: &Path,
233
+ agent_id: &str,
234
+ owner_team_id: Option<&str>,
235
+ ) -> Option<String> {
225
236
  let state = load_runtime_state(workspace).ok()?;
237
+ if let Some(owner) = owner_team_id.filter(|s| !s.is_empty()) {
238
+ if let Some(tasks) = state
239
+ .get("teams")
240
+ .and_then(|teams| teams.get(owner))
241
+ .and_then(|team| team.get("tasks"))
242
+ .and_then(Value::as_array)
243
+ {
244
+ if let Some(id) = find_latest_nonterminal_task_for(tasks, agent_id) {
245
+ return Some(id);
246
+ }
247
+ }
248
+ }
226
249
  let tasks = state.get("tasks").and_then(Value::as_array)?;
250
+ find_latest_nonterminal_task_for(tasks, agent_id)
251
+ }
252
+
253
+ fn find_latest_nonterminal_task_for(tasks: &[Value], agent_id: &str) -> Option<String> {
227
254
  for task in tasks.iter().rev() {
228
255
  let assignee = task.get("assignee").and_then(Value::as_str)?;
229
256
  if assignee != agent_id {
@@ -246,3 +273,47 @@ pub(crate) fn latest_task_for_assignee(workspace: &Path, agent_id: &str) -> Opti
246
273
  }
247
274
  None
248
275
  }
276
+
277
+ /// Find the most recent delivered direct message to `agent_id` whose `task_id`
278
+ /// is empty/null AND for which no result row exists yet. Used by
279
+ /// `report_result` as a message-scoped fallback before defaulting to
280
+ /// `"manual"`, so `collect` can correlate via the existing message-scope path
281
+ /// (`messaging::results::is_message_scoped_result`).
282
+ pub(crate) fn latest_uncorrelated_delivered_message_for(
283
+ workspace: &Path,
284
+ agent_id: &str,
285
+ owner_team_id: Option<&str>,
286
+ ) -> Option<String> {
287
+ use crate::db::message_store::MessageStore;
288
+ let store = MessageStore::open(workspace).ok()?;
289
+ let conn = crate::db::schema::open_db(store.db_path()).ok()?;
290
+ let sql = match owner_team_id {
291
+ Some(_) => "select m.message_id from messages m \
292
+ where m.recipient = ?1 and m.status = 'delivered' \
293
+ and (m.task_id is null or m.task_id = '') \
294
+ and m.owner_team_id = ?2 \
295
+ and not exists ( \
296
+ select 1 from results r \
297
+ where r.task_id = m.message_id and r.agent_id = m.recipient \
298
+ ) \
299
+ order by m.created_at desc limit 1",
300
+ None => "select m.message_id from messages m \
301
+ where m.recipient = ?1 and m.status = 'delivered' \
302
+ and (m.task_id is null or m.task_id = '') \
303
+ and not exists ( \
304
+ select 1 from results r \
305
+ where r.task_id = m.message_id and r.agent_id = m.recipient \
306
+ ) \
307
+ order by m.created_at desc limit 1",
308
+ };
309
+ let mut stmt = conn.prepare(sql).ok()?;
310
+ let id: Option<String> = match owner_team_id {
311
+ Some(team) => stmt
312
+ .query_row(rusqlite::params![agent_id, team], |row| row.get::<_, String>(0))
313
+ .ok(),
314
+ None => stmt
315
+ .query_row(rusqlite::params![agent_id], |row| row.get::<_, String>(0))
316
+ .ok(),
317
+ };
318
+ id
319
+ }
@@ -19,7 +19,8 @@ use crate::messaging::{self, MessageTarget, SendOptions};
19
19
 
20
20
  use super::helpers::{
21
21
  delivery_outcome_value, ensure_object, enum_value, insert_array, is_worker_recipient,
22
- json_dumps_default, latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
22
+ json_dumps_default, latest_task_for_assignee, latest_uncorrelated_delivered_message_for,
23
+ non_empty_string, normalized_envelope_value, object_fields,
23
24
  requires_ack_for_target, tool_runtime_error,
24
25
  };
25
26
  use super::normalize::{
@@ -292,11 +293,35 @@ impl TeamOrchestratorTools {
292
293
  obj.insert("status".to_string(), enum_value(status));
293
294
  }
294
295
  if !obj.contains_key("task_id") {
296
+ // Blocker-1 (prerelease 0.4.0): scoped-team task inference +
297
+ // message-scoped fallback, before defaulting to "manual".
298
+ // 1. explicit arg
299
+ // 2. latest nonterminal task assigned to this agent in
300
+ // teams.<owner>.tasks (scoped) → top-level tasks
301
+ // 3. latest delivered direct message to this agent with no
302
+ // task id and no result yet (message-scope correlation —
303
+ // collect path: is_message_scoped_result)
304
+ // 4. "manual" — truly uncorrelated; collect still rejects
305
+ let owner_team_id_str = self
306
+ .owner_team_id
307
+ .as_ref()
308
+ .map(|t| t.as_str().to_string());
295
309
  let resolved = task_id
296
310
  .map(ToString::to_string)
297
- .or_else(|| self.agent_id
298
- .as_ref()
299
- .and_then(|agent| latest_task_for_assignee(&self.workspace, agent.as_str())))
311
+ .or_else(|| self.agent_id.as_ref().and_then(|agent| {
312
+ latest_task_for_assignee(
313
+ &self.workspace,
314
+ agent.as_str(),
315
+ owner_team_id_str.as_deref(),
316
+ )
317
+ }))
318
+ .or_else(|| self.agent_id.as_ref().and_then(|agent| {
319
+ latest_uncorrelated_delivered_message_for(
320
+ &self.workspace,
321
+ agent.as_str(),
322
+ owner_team_id_str.as_deref(),
323
+ )
324
+ }))
300
325
  .unwrap_or_else(|| "manual".to_string());
301
326
  obj.insert("task_id".to_string(), Value::String(resolved));
302
327
  }
@@ -455,6 +455,18 @@ impl ProviderAdapter for BasicProviderAdapter {
455
455
  )?;
456
456
  argv.push("--session-id".to_string());
457
457
  argv.push(expected.clone());
458
+ // Layer 1 self-healing (architect probe 2026-06-22, claude help
459
+ // `-n, --name <name>`): pass `--name <agent_id>` so the
460
+ // resume picker and on-disk `~/.claude/sessions/*.json name`
461
+ // field carry our role label. This is a secondary diagnostic
462
+ // hint — primary restart key remains `session_id + backing
463
+ // store revalidation` (see provider/session/resume.rs).
464
+ if let Some(agent_id) = ctx.agent_id_hint {
465
+ if !agent_id.is_empty() {
466
+ argv.push("--name".to_string());
467
+ argv.push(agent_id.to_string());
468
+ }
469
+ }
458
470
  Ok(CommandPlan {
459
471
  argv,
460
472
  expected_session_id: Some(SessionId::new(expected)),
@@ -654,6 +666,15 @@ impl ProviderAdapter for BasicProviderAdapter {
654
666
  )?;
655
667
  argv.push("--resume".to_string());
656
668
  argv.push(session_id.as_str().to_string());
669
+ // Layer 1 self-healing: keep the role name on resume too —
670
+ // makes resumed sessions findable in the picker by agent_id
671
+ // when state.json drifts.
672
+ if let Some(agent_id) = ctx.agent_id_hint {
673
+ if !agent_id.is_empty() {
674
+ argv.push("--name".to_string());
675
+ argv.push(agent_id.to_string());
676
+ }
677
+ }
657
678
  let mut plan = CommandPlan::argv_only(argv);
658
679
  plan.provider_projects_root = ctx
659
680
  .profile_launch
@@ -1195,7 +1216,16 @@ fn candidate_session_files(
1195
1216
  collect_optional_candidate_files(&home.join(".codex").join("sessions"), &context.agent_id, &mut out)?;
1196
1217
  }
1197
1218
  Provider::Claude | Provider::ClaudeCode => {
1198
- collect_optional_candidate_files(&home.join(".claude").join("sessions"), &context.agent_id, &mut out)?;
1219
+ // Blocker-2 Layer-1 (prerelease 0.4.0): the activity rollout_path
1220
+ // MUST be the real transcript under .claude/projects/<encoded cwd>/
1221
+ // <session_id>.jsonl. ~/.claude/sessions/<pid>.json is a small
1222
+ // session-metadata file (~300-400 bytes) that contains a
1223
+ // sessionId but no lifecycle records; if it wins capture, the
1224
+ // activity classifier reads it forever and never sees the real
1225
+ // transcript. Architect verdict (bugs-prerelease-blockers.md §138):
1226
+ // do not let .claude/sessions/<pid>.json become rollout_path.
1227
+ // Skip that directory entirely; resume backing probe still scans
1228
+ // it via its own path checks in restart/common.rs.
1199
1229
  // E6 层1·B:优先锚到 ~/.claude/projects/<canonical spawn_cwd 编码> 子目录
1200
1230
  // (claude 把 cwd 的 '/' 编码成 '-';交互式 worker 的真实 transcript 落在此),
1201
1231
  // 而非全 projects 树盲扫(交互式 claude 自生成 UUID,锚 cwd 子目录 + 时间窗才能唯一选)。
@@ -16,6 +16,31 @@ pub fn runtime_mcp_prompt_allowlisted(prompt: &ApprovalPrompt) -> bool {
16
16
  && prompt.tool.as_deref().is_some_and(runtime_mcp_tool_allowlisted)
17
17
  }
18
18
 
19
+ /// Issue 1 (Round 3b gate review §6): Team-Agent control-plane MCP tools
20
+ /// that are safe to auto-approve INDEPENDENT of `leader_auto_approval_allowed`.
21
+ /// These tools never execute user-supplied commands; they are pure team-coord
22
+ /// state transitions. Without this carve-out, a worker spawned by a leader
23
+ /// that lacks a dangerous-bypass flag would have `report_result` blocked at
24
+ /// the MCP approval gate, breaking the entire result-collection pipeline.
25
+ ///
26
+ /// Strict scope:
27
+ /// * server MUST be `team_orchestrator` (RUNTIME_MCP_APPROVAL_SERVER) —
28
+ /// never broaden to arbitrary MCP servers.
29
+ /// * tool MUST be one of the explicit safe-list entries. `report_result`
30
+ /// is the canonical case; `send_message` is added because a worker
31
+ /// reporting status to leader through the message bus is the same
32
+ /// architectural layer as `report_result` and is harmless. New tools
33
+ /// join only after explicit product decision (per architect note).
34
+ pub fn runtime_mcp_tool_is_internal_control_plane(prompt: &ApprovalPrompt) -> bool {
35
+ if prompt.server.as_deref() != Some(RUNTIME_MCP_APPROVAL_SERVER) {
36
+ return false;
37
+ }
38
+ matches!(
39
+ prompt.tool.as_deref(),
40
+ Some("report_result") | Some("send_message")
41
+ )
42
+ }
43
+
19
44
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20
45
  #[serde(rename_all = "snake_case")]
21
46
  pub enum RuntimeApprovalDecision {
@@ -81,6 +106,15 @@ pub fn awaiting_human_confirm_reason(
81
106
  ApprovalKind::McpTool => {
82
107
  if !runtime_mcp_prompt_allowlisted(prompt) {
83
108
  Some("tool_not_allowlisted")
109
+ } else if runtime_mcp_tool_is_internal_control_plane(prompt) {
110
+ // Issue 1 (Round 3b gate review §6): Team-Agent control-plane
111
+ // MCP tools (report_result / send_message) auto-approve
112
+ // independent of leader dangerous-bypass. Without this,
113
+ // workers spawned under a non-bypass leader can never
114
+ // collect results — the entire correlation pipeline blocks
115
+ // at the MCP approval gate. Command approvals still go
116
+ // through `ApprovalKind::Command` and stay human-gated.
117
+ None
84
118
  } else if !leader_auto_approval_allowed {
85
119
  Some("leader_restricted")
86
120
  } else {
@@ -33,6 +33,28 @@ pub fn classify(
33
33
  ));
34
34
  }
35
35
  let facts = extract_lifecycle_facts(provider, &records);
36
+ // Blocker-2 Layer-2 (prerelease 0.4.0): Claude background-task lifecycle.
37
+ // Claude's long-running work (e.g. `Bash run_in_background:true`,
38
+ // followed by `task-notification status=completed`) can outlive the
39
+ // assistant turn that started it. The existing lifecycle-fact scan
40
+ // recognises `assistant.stop_reason=end_turn` as TurnComplete, so the
41
+ // classifier would otherwise mark the worker idle while a background
42
+ // shell continues. Synthesize a Working fact when at least one
43
+ // background task started but has not yet been closed. Architect
44
+ // verdict: bugs-prerelease-blockers.md §141-§143.
45
+ if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
46
+ if let Some(open_turn_id) = claude_background_task_open(&records) {
47
+ return Ok(decide_state(
48
+ &lifecycle(
49
+ FactKind::TurnOpen,
50
+ open_turn_id,
51
+ "background_task",
52
+ vec!["background_task".to_string()],
53
+ ),
54
+ process,
55
+ ));
56
+ }
57
+ }
36
58
  let Some(fact) = facts.last() else {
37
59
  return Ok(classify_result(
38
60
  TurnState::Unknown,
@@ -45,6 +67,122 @@ pub fn classify(
45
67
  Ok(decide_state(fact, process))
46
68
  }
47
69
 
70
+ /// Blocker-2 Layer-2: scan Claude transcript records for background task
71
+ /// lifecycle markers. Returns `Some(turn_id)` when at least one background
72
+ /// task is OPEN (started but not closed) — caller treats as `TurnOpen` with
73
+ /// reason `background_task`. Returns `None` when no background task started,
74
+ /// or every started task has a matching close (completed / failed).
75
+ ///
76
+ /// Open markers (set):
77
+ /// * Any record nested string containing
78
+ /// "Command running in background with ID: <id>" (Bash tool output).
79
+ /// * `tool_use_result.backgroundTaskId` (the structured form).
80
+ ///
81
+ /// Close markers (clear):
82
+ /// * `task-notification` content with `<task-id>` matching open id and
83
+ /// `<status>completed</status>` or `failed`.
84
+ /// * `tool_use_result.bashOutput.status = "completed" | "failed"` for the
85
+ /// matching backgroundTaskId.
86
+ fn claude_background_task_open(records: &[serde_json::Value]) -> Option<Option<TurnId>> {
87
+ use std::collections::BTreeSet;
88
+ let mut open: BTreeSet<String> = BTreeSet::new();
89
+ let mut last_open_request: Option<String> = None;
90
+ for record in records {
91
+ let mut found_open_ids: Vec<String> = Vec::new();
92
+ collect_background_open_ids(record, &mut found_open_ids);
93
+ for id in &found_open_ids {
94
+ open.insert(id.clone());
95
+ }
96
+ if !found_open_ids.is_empty() {
97
+ if let Some(req) = record.get("requestId").and_then(serde_json::Value::as_str) {
98
+ last_open_request = Some(req.to_string());
99
+ }
100
+ }
101
+ let mut closed_ids: Vec<String> = Vec::new();
102
+ collect_background_close_ids(record, &mut closed_ids);
103
+ for id in &closed_ids {
104
+ open.remove(id);
105
+ }
106
+ }
107
+ if open.is_empty() {
108
+ None
109
+ } else {
110
+ Some(last_open_request.map(TurnId::new))
111
+ }
112
+ }
113
+
114
+ fn collect_background_open_ids(value: &serde_json::Value, out: &mut Vec<String>) {
115
+ const MARKER: &str = "Command running in background with ID: ";
116
+ match value {
117
+ serde_json::Value::String(s) => {
118
+ for piece in s.split(MARKER).skip(1) {
119
+ let id: String = piece.chars().take_while(|c| !c.is_whitespace() && *c != '\n').collect();
120
+ if !id.is_empty() {
121
+ out.push(id);
122
+ }
123
+ }
124
+ }
125
+ serde_json::Value::Object(map) => {
126
+ if let Some(id) = map.get("backgroundTaskId").and_then(serde_json::Value::as_str) {
127
+ if !id.is_empty() {
128
+ out.push(id.to_string());
129
+ }
130
+ }
131
+ for v in map.values() {
132
+ collect_background_open_ids(v, out);
133
+ }
134
+ }
135
+ serde_json::Value::Array(arr) => {
136
+ for v in arr {
137
+ collect_background_open_ids(v, out);
138
+ }
139
+ }
140
+ _ => {}
141
+ }
142
+ }
143
+
144
+ fn collect_background_close_ids(value: &serde_json::Value, out: &mut Vec<String>) {
145
+ // Form A: tool_use_result.bashOutput { backgroundTaskId, status } where status is
146
+ // "completed" or "failed".
147
+ // Form B: <task-notification> wrapper in a string with a status tag.
148
+ if let serde_json::Value::Object(map) = value {
149
+ if let Some(bash) = map.get("bashOutput").and_then(serde_json::Value::as_object) {
150
+ let status = bash.get("status").and_then(serde_json::Value::as_str).unwrap_or("");
151
+ if matches!(status, "completed" | "failed" | "killed") {
152
+ if let Some(id) = bash.get("backgroundTaskId").and_then(serde_json::Value::as_str) {
153
+ if !id.is_empty() {
154
+ out.push(id.to_string());
155
+ }
156
+ }
157
+ }
158
+ }
159
+ for v in map.values() {
160
+ collect_background_close_ids(v, out);
161
+ }
162
+ } else if let serde_json::Value::Array(arr) = value {
163
+ for v in arr {
164
+ collect_background_close_ids(v, out);
165
+ }
166
+ } else if let serde_json::Value::String(s) = value {
167
+ // Form B: <task-notification ...><task-id>X</task-id>...<status>completed</status>...
168
+ if s.contains("<task-notification") && s.contains("<status>completed</status>") {
169
+ if let Some(id) = extract_xml_tag(s, "task-id") {
170
+ if !id.is_empty() {
171
+ out.push(id);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
179
+ let open = format!("<{tag}>");
180
+ let close = format!("</{tag}>");
181
+ let start = text.find(&open)? + open.len();
182
+ let end_rel = text[start..].find(&close)?;
183
+ Some(text[start..start + end_rel].trim().to_string())
184
+ }
185
+
48
186
  pub fn latest_explicit_error_fact(provider: Provider, session_log_text: &str) -> Option<FaultFact> {
49
187
  let record = latest_jsonl_record(session_log_text)?;
50
188
  match provider {