@team-agent/installer 0.5.63 → 0.5.65

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 (37) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -0
  4. package/crates/team-agent/src/cli/emit.rs +15 -0
  5. package/crates/team-agent/src/cli/mod.rs +9 -8
  6. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/mod.rs +31 -0
  9. package/crates/team-agent/src/cli/tests/status_send.rs +13 -3
  10. package/crates/team-agent/src/cli/types.rs +7 -0
  11. package/crates/team-agent/src/coordinator/health.rs +17 -0
  12. package/crates/team-agent/src/coordinator/steps/abnormal.rs +55 -2
  13. package/crates/team-agent/src/db/migration.rs +2 -1
  14. package/crates/team-agent/src/db/schema.rs +16 -8
  15. package/crates/team-agent/src/leader/lease.rs +1 -2
  16. package/crates/team-agent/src/leader/start.rs +8 -8
  17. package/crates/team-agent/src/lifecycle/restart/agent.rs +51 -9
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -3
  19. package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -139
  20. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +148 -5
  21. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +45 -13
  22. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +10 -6
  23. package/crates/team-agent/src/lifecycle/tests.rs +11 -5
  24. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  25. package/crates/team-agent/src/messaging/delivery.rs +29 -0
  26. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +12 -0
  28. package/crates/team-agent/src/messaging/mod.rs +2 -0
  29. package/crates/team-agent/src/messaging/results.rs +5 -0
  30. package/crates/team-agent/src/messaging/send.rs +9 -0
  31. package/crates/team-agent/src/messaging/tests/runtime.rs +32 -0
  32. package/crates/team-agent/src/messaging/types.rs +2 -0
  33. package/crates/team-agent/src/messaging/wait.rs +366 -0
  34. package/crates/team-agent/src/messaging/watchers.rs +153 -6
  35. package/crates/team-agent/src/tmux_backend.rs +16 -12
  36. package/crates/team-agent/src/transport.rs +11 -11
  37. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.63"
578
+ version = "0.5.65"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.63"
12
+ version = "0.5.65"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -523,6 +523,11 @@ pub fn cmd_results(args: &ResultsArgs) -> Result<CmdResult, CliError> {
523
523
  })
524
524
  }
525
525
 
526
+ pub fn cmd_wait(args: &WaitArgs) -> Result<CmdResult, CliError> {
527
+ let result = messaging::wait_for_result(&args.workspace, &args.task_id)?;
528
+ Ok(CmdResult::from_json(result.to_json(), args.json))
529
+ }
530
+
526
531
  /// `cmd_allow_peer_talk`(`parser.py allow-peer-talk`).
527
532
  pub fn cmd_allow_peer_talk(args: &AllowPeerTalkArgs) -> Result<CmdResult, CliError> {
528
533
  if args.team.is_some() {
@@ -196,6 +196,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
196
196
  .map(emit_result)
197
197
  }
198
198
  "results" => cmd_results(&results_args(args, cwd)?).map(emit_result),
199
+ "wait" => cmd_wait(&wait_args(args, cwd)?).map(emit_result),
199
200
  "diagnose" => cmd_diagnose(&diagnose_args(args, cwd)).map(emit_result),
200
201
  "preflight" => cmd_preflight(&preflight_args(args, cwd)).map(emit_result),
201
202
  "wait-ready" => cmd_wait_ready(&wait_ready_args(args, cwd)).map(emit_result),
@@ -243,6 +244,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
243
244
  "profile",
244
245
  "collect",
245
246
  "results",
247
+ "wait",
246
248
  "diagnose",
247
249
  "preflight",
248
250
  "wait-ready",
@@ -403,6 +405,7 @@ fn command_help(command: Option<&str>) -> String {
403
405
  Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]".to_string(),
404
406
  Some("collect") => "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]".to_string(),
405
407
  Some("results") => "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
408
+ Some("wait") => "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]".to_string(),
406
409
  Some("diagnose") => "usage: team-agent diagnose [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
407
410
  Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
408
411
  Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]".to_string(),
@@ -1319,6 +1322,18 @@ fn watch_args(args: &[String], cwd: &Path) -> WatchArgs {
1319
1322
  }
1320
1323
  }
1321
1324
 
1325
+ fn wait_args(args: &[String], cwd: &Path) -> Result<WaitArgs, CliError> {
1326
+ let parsed = parse_args(args);
1327
+ let workspace = workspace(&parsed, cwd);
1328
+ Ok(WaitArgs {
1329
+ task_id: parsed
1330
+ .task
1331
+ .ok_or_else(|| CliError::Usage("wait requires --task <id>".to_string()))?,
1332
+ workspace,
1333
+ json: parsed.json,
1334
+ })
1335
+ }
1336
+
1322
1337
  fn approvals_args(args: &[String], cwd: &Path) -> ApprovalsArgs {
1323
1338
  let parsed = parse_args(args);
1324
1339
  ApprovalsArgs {
@@ -2245,14 +2245,15 @@ pub mod lifecycle_port {
2245
2245
  /// runs, where the leader is never in the invoker's ancestry.
2246
2246
  ///
2247
2247
  /// 0.4.x (CR R3): leader shell wrapper interaction. The leader pane's
2248
- /// controlling process is the `sh -lc "...; exec ${SHELL} -l"` that
2249
- /// runs Claude as a CHILD. When Claude exits and the shell wrapper falls
2250
- /// back via `exec ${SHELL} -l`, the controlling PID is REPLACED in-place
2251
- /// by the interactive shell (same pane_pid). Because this function
2252
- /// protects by `pane.pane_pid` (Source 1 & 2), the fallback interactive
2253
- /// shell is already covered by the same protection set shutdown will
2254
- /// NOT treat the fallback shell as a stray process. Verified by the
2255
- /// `leader_fallback_shell_protected_when_provider_exited` test.
2248
+ /// controlling process is the `sh -lc` wrapper that runs Claude as a
2249
+ /// CHILD. When Claude exits, the wrapper records the marker and replaces
2250
+ /// its command with the inert `/bin/sh -c` tail (which ignores INT/QUIT
2251
+ /// and does not read pane stdin). Because this function protects by
2252
+ /// `pane.pane_pid` (Source 1 & 2), that tail is covered by the same
2253
+ /// protection set shutdown will NOT treat it as a stray process.
2254
+ /// This protection is currently untested; the former claim that
2255
+ /// `leader_fallback_shell_protected_when_provider_exited` verified it
2256
+ /// referred to a test absent from this repository (A-46).
2256
2257
  ///
2257
2258
  /// Two leader-pane sources(N39 双来源,真机 grounded):
2258
2259
  /// 1. **Session prefix**: tmux session starts with `team-agent-leader-`(契约 grounded;
@@ -108,6 +108,7 @@ pub(super) fn delivery_outcome_json(
108
108
  "sender": opts.sender,
109
109
  "message_id": outcome.message_id,
110
110
  "message_status": outcome.message_status.0,
111
+ "ack_forced_off": outcome.ack_forced_off,
111
112
  "verification": outcome.verification,
112
113
  "stage": outcome.stage.map(delivery_stage_wire),
113
114
  "reason": outcome.reason.map(delivery_refusal_wire),
@@ -78,6 +78,7 @@ pub(crate) enum DispatchKind {
78
78
  Profile,
79
79
  Collect,
80
80
  Results,
81
+ Wait,
81
82
  Diagnose,
82
83
  Preflight,
83
84
  WaitReady,
@@ -123,6 +124,7 @@ pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
123
124
  DispatchKind::Profile,
124
125
  DispatchKind::Collect,
125
126
  DispatchKind::Results,
127
+ DispatchKind::Wait,
126
128
  DispatchKind::Diagnose,
127
129
  DispatchKind::Preflight,
128
130
  DispatchKind::WaitReady,
@@ -162,6 +164,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
162
164
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
163
165
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
164
166
  CommandSpec { name: "results", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Results), summary: "read reported results for a case", usage: "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
167
+ CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
165
168
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
166
169
  CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
167
170
  CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add or force-recreate a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -42,6 +42,37 @@ fn seed_status_workspace() -> std::path::PathBuf {
42
42
  dir
43
43
  }
44
44
 
45
+ fn seed_send_workspace() -> std::path::PathBuf {
46
+ let dir = std::env::temp_dir().join(format!(
47
+ "ta-cli-send-{}-{}",
48
+ std::process::id(),
49
+ std::time::SystemTime::now()
50
+ .duration_since(std::time::UNIX_EPOCH)
51
+ .unwrap()
52
+ .as_nanos()
53
+ ));
54
+ let team = dir.join(".team");
55
+ std::fs::create_dir_all(team.join("runtime")).unwrap();
56
+ let state = json!({
57
+ "active_team_key": "teamA",
58
+ "teams": {
59
+ "teamA": {
60
+ "agents": {
61
+ "alice": {"provider": "codex"},
62
+ "drifted": {"provider": "codex", "status": "session_drift"}
63
+ },
64
+ "tasks": [{"id": "t-1"}]
65
+ }
66
+ }
67
+ });
68
+ std::fs::write(
69
+ team.join("runtime").join("state.json"),
70
+ serde_json::to_vec_pretty(&state).unwrap(),
71
+ )
72
+ .unwrap();
73
+ dir
74
+ }
75
+
45
76
  const DELEG_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: fake\nmodel: fake\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
46
77
  const DELEG_INVALID_ROLE: &str = "---\nname: broken\nrole: Broken Worker\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nNo provider field.\n";
47
78
  const DELEG_TEAM_MD: &str =
@@ -453,11 +453,13 @@ fn status_port_status_detail_full_keeps_uncompacted_events() {
453
453
  // =========================================================================
454
454
 
455
455
  fn send_args_fixture() -> SendArgs {
456
+ let workspace = seed_send_workspace();
457
+ let _ = crate::message_store::MessageStore::open(&workspace).unwrap();
456
458
  SendArgs {
457
459
  target: Some("alice".into()),
458
460
  message: vec!["hello".into(), "world".into(), "foo".into()],
459
461
  targets: None,
460
- workspace: PathBuf::from("."),
462
+ workspace,
461
463
  team: Some("teamA".into()),
462
464
  task: Some("t-1".into()),
463
465
  sender: TrustedSender::leader(),
@@ -565,9 +567,17 @@ fn cmd_send_joins_message_with_single_space() {
565
567
  CmdOutput::Json(ref v) => {
566
568
  assert!(v.get("ok").is_some(), "send result Json must carry `ok`");
567
569
  if v.get("ok").and_then(|ok| ok.as_bool()) == Some(true) {
570
+ let delivered = v.get("delivered").and_then(|x| x.as_bool()) == Some(true);
571
+ // hermetic tier 假设:此 fixture 无法产生真实 tmux Delivered。
572
+ // 若 delivered=true 命中说明 tier 前提变了(例如 seed 开了 Delivered 通路),
573
+ // 应立即失败以强制重新审视,而不是悄悄跑一条从未验过的分支。
574
+ assert!(
575
+ !delivered,
576
+ "hermetic seed 不应产生 delivered=true;tier 假设变了"
577
+ );
568
578
  assert_eq!(
569
579
  v.get("reminder").and_then(|reminder| reminder.as_str()),
570
- Some(crate::cli::SEND_REMINDER)
580
+ Some("Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane.")
571
581
  );
572
582
  }
573
583
  }
@@ -689,7 +699,7 @@ fn cmd_send_failed_outcome_yields_error_exit() {
689
699
  // DeliveryOutcome ok=false (e.g. refused) -> from_json -> ExitCode::Error (parser.py:507).
690
700
  // A failed send to a target must propagate non-zero exit reporting through CmdResult.
691
701
  let args = SendArgs {
692
- target: Some("nonexistent".into()),
702
+ target: Some("drifted".into()),
693
703
  no_ack: false,
694
704
  no_wait: false,
695
705
  watch_result: false,
@@ -677,6 +677,13 @@ pub struct ResultsArgs {
677
677
  pub json: bool,
678
678
  }
679
679
 
680
+ #[derive(Debug, Clone, PartialEq, Eq)]
681
+ pub struct WaitArgs {
682
+ pub task_id: String,
683
+ pub workspace: PathBuf,
684
+ pub json: bool,
685
+ }
686
+
680
687
  /// `diagnose`(`parser.py:298`) runtime health report, distinct from `doctor`.
681
688
  #[derive(Debug, Clone, PartialEq, Eq)]
682
689
  pub struct DiagnoseArgs {
@@ -1064,6 +1064,23 @@ pub fn render_event_line(event: &Value) -> Option<String> {
1064
1064
  clean_field(event, &["provider"], "-"),
1065
1065
  clean_field(event, &["matched_pattern_snippet", "snippet"], "-")
1066
1066
  )),
1067
+ "result_wake.registered" => Some(format!(
1068
+ "result_wake.registered: task={} watcher={}",
1069
+ clean_field(event, &["task_id"], "-"),
1070
+ clean_field(event, &["watcher_id"], "-")
1071
+ )),
1072
+ "result_wake.notified" => Some(format!(
1073
+ "result_wake.notified: task={} result={} watcher={}",
1074
+ clean_field(event, &["task_id"], "-"),
1075
+ clean_field(event, &["result_id"], "-"),
1076
+ clean_field(event, &["watcher_id"], "-")
1077
+ )),
1078
+ "result_wake.notify_failed" => Some(format!(
1079
+ "result_wake.notify_failed: task={} watcher={} reason={}",
1080
+ clean_field(event, &["task_id"], "-"),
1081
+ clean_field(event, &["watcher_id"], "-"),
1082
+ clean_field(event, &["reason", "error"], "-")
1083
+ )),
1067
1084
  _ => None,
1068
1085
  }
1069
1086
  }
@@ -578,7 +578,7 @@ fn agent_process_liveness(
578
578
  // pane for POSITIVE provider evidence via current-command + worker
579
579
  // exit marker. Under the 0.5.39 wrapper, `state.status=running` is
580
580
  // administrative spawn accounting, not runtime truth — a provider
581
- // may have exited into the shell fallback while state still reads
581
+ // may have exited into the inert shell tail while state still reads
582
582
  // running. The marker probe positively proves that case; when it
583
583
  // fires we return Dead here instead of the false Alive further
584
584
  // down.
@@ -795,9 +795,29 @@ fn worker_provider_exit_marker_check(
795
795
  .capture(&target, crate::transport::CaptureRange::Tail(200))
796
796
  .ok()?;
797
797
  if cap.text.contains(&marker) {
798
+ // The wrapper writes its exit code immediately after this marker. A
799
+ // truncated capture can leave the marker without a complete token;
800
+ // keep that case explicit rather than guessing success (or zero).
801
+ // Scope: this detail only covers exits that execute the wrapper's
802
+ // marker printf. SIGKILL, direct pane termination, and host power loss
803
+ // produce no marker; that absence is silent, not `rc=unknown`.
804
+ let rc = cap
805
+ .text
806
+ .split_once(&marker)
807
+ .and_then(|(_, tail)| {
808
+ let token = tail.split_whitespace().next()?;
809
+ let end = tail.find(token)? + token.len();
810
+ tail.as_bytes()
811
+ .get(end)
812
+ .is_some_and(u8::is_ascii_whitespace)
813
+ .then_some(token)
814
+ })
815
+ .and_then(|token| token.parse::<i32>().ok())
816
+ .map(|value| value.to_string())
817
+ .unwrap_or_else(|| "unknown".to_string());
798
818
  Some(process_check(
799
819
  ProcessLiveness::Dead,
800
- format!("worker_provider_exited:{pane_id_str}"),
820
+ format!("worker_provider_exited:{pane_id_str}:rc={rc}"),
801
821
  ))
802
822
  } else {
803
823
  None
@@ -1816,6 +1836,39 @@ mod tests {
1816
1836
  );
1817
1837
  }
1818
1838
 
1839
+ #[test]
1840
+ fn worker_exit_marker_detail_includes_exit_code() {
1841
+ let pane_id = crate::transport::PaneId::new("%marker-rc");
1842
+ let marker = crate::tmux_backend::worker_provider_exit_marker("codex");
1843
+ let transport = crate::transport::test_support::OfflineTransport::new()
1844
+ .with_capture_for_pane(pane_id.as_str(), format!("\n{marker} 23\n"));
1845
+ let mut agent = test_abnormal_agent("/tmp/rollout.jsonl", Some(1), None);
1846
+ agent.pane_id = Some(pane_id.as_str().to_string());
1847
+
1848
+ let check = worker_provider_exit_marker_check(&agent, &transport).expect("marker hit");
1849
+
1850
+ assert_eq!(check.state, ProcessLiveness::Dead);
1851
+ assert_eq!(check.detail, "worker_provider_exited:%marker-rc:rc=23");
1852
+ }
1853
+
1854
+ #[test]
1855
+ fn worker_exit_marker_detail_uses_unknown_for_truncated_exit_code() {
1856
+ let pane_id = crate::transport::PaneId::new("%marker-truncated");
1857
+ let marker = crate::tmux_backend::worker_provider_exit_marker("codex");
1858
+ let transport = crate::transport::test_support::OfflineTransport::new()
1859
+ .with_capture_for_pane(pane_id.as_str(), format!("\n{marker} "));
1860
+ let mut agent = test_abnormal_agent("/tmp/rollout.jsonl", Some(1), None);
1861
+ agent.pane_id = Some(pane_id.as_str().to_string());
1862
+
1863
+ let check = worker_provider_exit_marker_check(&agent, &transport).expect("marker hit");
1864
+
1865
+ assert_eq!(check.state, ProcessLiveness::Dead);
1866
+ assert_eq!(
1867
+ check.detail,
1868
+ "worker_provider_exited:%marker-truncated:rc=unknown"
1869
+ );
1870
+ }
1871
+
1819
1872
  #[test]
1820
1873
  fn abnormal_dead_fresh_error_event_reports_actual_dead_booleans() {
1821
1874
  let dir = temp_abnormal_dir("dead-fresh");
@@ -98,6 +98,7 @@ pub const MANAGED_TABLE_LAYOUTS: &[(&str, &[&str])] = &[
98
98
  "agent_id",
99
99
  "message_id",
100
100
  "leader_id",
101
+ "recipient",
101
102
  "status",
102
103
  "created_at",
103
104
  "completed_at",
@@ -129,7 +130,7 @@ const CREATE_TABLE_TEMPLATES: &[(&str, &str)] = &[
129
130
  ("delivery_tokens", "create table if not exists __TABLE__ (\n message_id text primary key,\n unique_token text not null,\n injected_at text not null,\n visible_at text,\n consumed_at text,\n failed_at text,\n failure_reason text\n )"),
130
131
  ("agent_health", "create table if not exists __TABLE__ (\n owner_team_id text,\n agent_id text not null,\n status text not null,\n last_output_at text,\n context_usage_pct integer,\n current_task_id text,\n updated_at text not null,\n unique(owner_team_id, agent_id)\n )"),
131
132
  ("peer_allowlist", "create table if not exists __TABLE__ (\n a text not null,\n b text not null,\n created_at text not null,\n primary key (a, b)\n )"),
132
- ("result_watchers", "create table if not exists __TABLE__ (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )"),
133
+ ("result_watchers", "create table if not exists __TABLE__ (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n recipient text,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )"),
133
134
  ("leader_notification_log", "create table if not exists __TABLE__ (\n result_id text not null,\n owner_team_id text not null default '',\n owner_epoch integer not null default 0,\n leader_session_uuid text,\n notified_message_id text not null,\n notified_at text not null,\n leader_pane_id_at_notify text,\n envelope_content_hash text,\n primary key (result_id, owner_team_id, owner_epoch)\n )"),
134
135
  ];
135
136
 
@@ -13,7 +13,7 @@ use rusqlite::Connection;
13
13
  use crate::db::DbError;
14
14
 
15
15
  /// `schema.py:90`。
16
- pub const SCHEMA_VERSION: i64 = 4;
16
+ pub const SCHEMA_VERSION: i64 = 5;
17
17
 
18
18
  /// 8 张表的 DDL(逐字照搬 `schema.py:initialize_schema` 的内联建表;含 `if not exists`)。
19
19
  /// 顺序与 Python 一致(leader_notification_log 在 ensure 块后创建)。
@@ -23,7 +23,7 @@ const CREATE_SCHEDULED_EVENTS: &str = "create table if not exists scheduled_even
23
23
  const CREATE_DELIVERY_TOKENS: &str = "create table if not exists delivery_tokens (\n message_id text primary key,\n unique_token text not null,\n injected_at text not null,\n visible_at text,\n consumed_at text,\n failed_at text,\n failure_reason text\n )";
24
24
  const CREATE_AGENT_HEALTH: &str = "create table if not exists agent_health (\n owner_team_id text,\n agent_id text not null,\n status text not null,\n last_output_at text,\n context_usage_pct integer,\n current_task_id text,\n updated_at text not null,\n unique(owner_team_id, agent_id)\n )";
25
25
  const CREATE_PEER_ALLOWLIST: &str = "create table if not exists peer_allowlist (\n a text not null,\n b text not null,\n created_at text not null,\n primary key (a, b)\n )";
26
- const CREATE_RESULT_WATCHERS: &str = "create table if not exists result_watchers (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )";
26
+ const CREATE_RESULT_WATCHERS: &str = "create table if not exists result_watchers (\n watcher_id text primary key,\n owner_team_id text,\n task_id text,\n agent_id text,\n message_id text,\n leader_id text not null,\n recipient text,\n status text not null,\n created_at text not null,\n completed_at text,\n result_id text,\n notified_message_id text,\n error text\n )";
27
27
  const CREATE_LEADER_NOTIFICATION_LOG: &str = "create table if not exists leader_notification_log (\n result_id text not null,\n owner_team_id text not null default '',\n owner_epoch integer not null default 0,\n leader_session_uuid text,\n notified_message_id text not null,\n notified_at text not null,\n leader_pane_id_at_notify text,\n envelope_content_hash text,\n primary key (result_id, owner_team_id, owner_epoch)\n )";
28
28
  const CREATE_AGENT_HEALTH_NEW: &str = "create table agent_health_new (\n owner_team_id text,\n agent_id text not null,\n status text not null,\n last_output_at text,\n context_usage_pct integer,\n current_task_id text,\n updated_at text not null,\n unique(owner_team_id, agent_id)\n )";
29
29
 
@@ -104,6 +104,7 @@ const RESULT_WATCHER_COLUMNS: &[&str] = &[
104
104
  "agent_id",
105
105
  "message_id",
106
106
  "leader_id",
107
+ "recipient",
107
108
  "status",
108
109
  "created_at",
109
110
  "completed_at",
@@ -297,10 +298,16 @@ pub fn initialize_schema(
297
298
  &tx,
298
299
  "result_watchers",
299
300
  RESULT_WATCHER_COLUMNS,
300
- &[(
301
- "owner_team_id",
302
- "alter table result_watchers add column owner_team_id text",
303
- )],
301
+ &[
302
+ (
303
+ "owner_team_id",
304
+ "alter table result_watchers add column owner_team_id text",
305
+ ),
306
+ (
307
+ "recipient",
308
+ "alter table result_watchers add column recipient text",
309
+ ),
310
+ ],
304
311
  )?;
305
312
  tx.execute(CREATE_LEADER_NOTIFICATION_LOG, [])?;
306
313
  ensure_table_columns(
@@ -360,13 +367,13 @@ mod tests {
360
367
  }
361
368
 
362
369
  #[test]
363
- fn user_version_is_four() {
370
+ fn user_version_is_five() {
364
371
  let conn = fresh();
365
372
  let v: i64 = conn
366
373
  .query_row("pragma user_version", [], |r| r.get(0))
367
374
  .unwrap();
368
375
  assert_eq!(v, SCHEMA_VERSION);
369
- assert_eq!(v, 4);
376
+ assert_eq!(v, 5);
370
377
  }
371
378
 
372
379
  #[test]
@@ -513,6 +520,7 @@ mod tests {
513
520
  ("agent_id", "TEXT", 0, None, 0),
514
521
  ("message_id", "TEXT", 0, None, 0),
515
522
  ("leader_id", "TEXT", 1, None, 0),
523
+ ("recipient", "TEXT", 0, None, 0),
516
524
  ("status", "TEXT", 1, None, 0),
517
525
  ("created_at", "TEXT", 1, None, 0),
518
526
  ("completed_at", "TEXT", 0, None, 0),
@@ -505,7 +505,6 @@ pub fn claim_leader(
505
505
  team: Option<&str>,
506
506
  confirm: bool,
507
507
  ) -> Result<LeaseResult, LeaderError> {
508
- let _ = confirm;
509
508
  let caller = std::env::var("TMUX_PANE")
510
509
  .ok()
511
510
  .filter(|pane| !pane.is_empty())
@@ -598,7 +597,7 @@ pub fn claim_leader(
598
597
  Some(team_id.as_str()),
599
598
  &team_id,
600
599
  &PaneId::new(caller),
601
- true,
600
+ confirm,
602
601
  &event_log,
603
602
  &liveness,
604
603
  caller_target.as_ref(),
@@ -1269,12 +1269,12 @@ fn ensure_managed_provider_live_after_attach(
1269
1269
  /// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
1270
1270
  /// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
1271
1271
  /// returns `Live` even when the provider has exited and the wrapper shell
1272
- /// fell back to an interactive shell. This function distinguishes
1272
+ /// remains at its inert tail. This function distinguishes
1273
1273
  /// `provider_alive` from `provider_exited` by:
1274
1274
  /// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
1275
1275
  /// 2. If the current command matches the expected provider binary (or
1276
1276
  /// one of its known aliases), report `Alive`.
1277
- /// 3. If the current command is an interactive shell AND the pane
1277
+ /// 3. If the current command is a shell tail process AND the pane
1278
1278
  /// content contains the exit marker `[team-agent] <provider> exited`
1279
1279
  /// (emitted by `leader_shell_wrapper_command`), report
1280
1280
  /// `ProviderExited`.
@@ -1282,7 +1282,7 @@ fn ensure_managed_provider_live_after_attach(
1282
1282
  /// conservative default — avoid false-positive exit alarms.
1283
1283
  ///
1284
1284
  /// Note: when the leader shell wrapper is used (CR C-2), a provider exit
1285
- /// leaves the pane as `<SHELL:-/bin/zsh>` with the exit marker in
1285
+ /// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
1286
1286
  /// scrollback. Pre-wrapper code that hit `exec claude` would have left the
1287
1287
  /// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
1288
1288
  /// failure mode requires this richer health check to surface
@@ -1340,16 +1340,16 @@ pub enum LeaderProviderHealth {
1340
1340
  Unreachable,
1341
1341
  }
1342
1342
 
1343
- /// 0.4.x (CR R6 + R3): single-source interactive-shell detection.
1343
+ /// 0.4.x (CR R6 + R3): single-source shell-tail detection.
1344
1344
  /// Used by:
1345
- /// - `leader_provider_health` to decide "pane fell back to shell"
1346
- /// - shutdown logic to recognise a leader pane in fallback-shell mode
1345
+ /// - `leader_provider_health` to decide "pane is at the shell tail"
1346
+ /// - shutdown logic to recognise a leader pane in shell-tail mode
1347
1347
  /// as still owned by the leader (not stray).
1348
1348
  ///
1349
1349
  /// Matches by basename (case-insensitive) — `pane_current_command` returns
1350
1350
  /// the basename of the running binary. Conservative whitelist of POSIX +
1351
- /// common interactive shells; missing entries here are false negatives
1352
- /// (shell looks like provider absent → health says Alive) which is the
1351
+ /// common shell processes; missing entries here are false negatives
1352
+ /// (shell tail looks like provider absent → health says Alive) which is the
1353
1353
  /// safe default per the CR R6 conservative-Alive rule.
1354
1354
  fn is_interactive_shell_basename(name: &str) -> bool {
1355
1355
  let trimmed = name.trim().to_ascii_lowercase();
@@ -131,8 +131,35 @@ pub(crate) fn start_agent_at_paths(
131
131
  return Err(LifecycleError::RequirementUnmet(error));
132
132
  }
133
133
  }
134
- let agent_live = if adaptive_layout {
135
- agent_pane_live(transport, &raw_agent)
134
+ // A persisted window name is only a topology hint. Once a pane binding is
135
+ // recorded, the pane must be both physically live and owned by the
136
+ // intended session/window; a foreign pane must never make start-agent
137
+ // return Noop. A coordinator-provided pane_dead reason is also
138
+ // authoritative when a backend cannot answer the exact-pane probe.
139
+ let pane_marked_dead = raw_agent
140
+ .get("stale_reason")
141
+ .and_then(serde_json::Value::as_str)
142
+ .is_some_and(|reason| matches!(reason, "pane_dead" | "both"));
143
+ // A stale cached pane may still be recoverable without a spawn when the
144
+ // intended per-agent window has exactly one live pane. The pane found by
145
+ // this session/window lookup, not the cached id, is the binding that the
146
+ // Noop path refreshes. A foreign pane with no intended window still falls
147
+ // through to a real spawn.
148
+ let noop_pane = if adaptive_layout {
149
+ None
150
+ } else {
151
+ single_live_pane_for_window(transport, &session_name, &window)
152
+ };
153
+ let agent_live = if pane_marked_dead {
154
+ false
155
+ } else if adaptive_layout
156
+ || raw_agent
157
+ .get("pane_id")
158
+ .and_then(serde_json::Value::as_str)
159
+ .is_some_and(|pane| !pane.is_empty())
160
+ {
161
+ agent_pane_owned_and_live(transport, &raw_agent, &session_name, &window)
162
+ || noop_pane.is_some()
136
163
  } else {
137
164
  window_exists(transport, &session_name, &window)
138
165
  };
@@ -145,11 +172,6 @@ pub(crate) fn start_agent_at_paths(
145
172
  // state — assert_topology_invariants from Step 1 catches the
146
173
  // upstream corruption.
147
174
  let has_collision = pane_conflicts_with_leader_or_other(&state, agent_id, &raw_agent);
148
- let noop_pane = if adaptive_layout {
149
- None
150
- } else {
151
- single_live_pane_for_window(transport, &session_name, &window)
152
- };
153
175
  if has_collision && noop_pane.is_none() {
154
176
  eprintln!(
155
177
  "team_agent::layout e51_collision_post_step2 agent_id=`{agent_id}` \
@@ -603,7 +625,12 @@ fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_
603
625
  })
604
626
  }
605
627
 
606
- fn agent_pane_live(transport: &dyn crate::transport::Transport, agent: &serde_json::Value) -> bool {
628
+ fn agent_pane_owned_and_live(
629
+ transport: &dyn crate::transport::Transport,
630
+ agent: &serde_json::Value,
631
+ expected_session: &crate::transport::SessionName,
632
+ expected_window: &str,
633
+ ) -> bool {
607
634
  let Some(pane) = agent
608
635
  .get("pane_id")
609
636
  .and_then(serde_json::Value::as_str)
@@ -612,7 +639,22 @@ fn agent_pane_live(transport: &dyn crate::transport::Transport, agent: &serde_js
612
639
  else {
613
640
  return false;
614
641
  };
615
- agent_pane_live_by_id(transport, &pane)
642
+ let Ok(targets) = transport.list_targets() else {
643
+ // Ownership is unknown when the topology snapshot fails. Preserve the
644
+ // base liveness direction: only positive death evidence may authorize
645
+ // a destructive respawn; otherwise fall back to the pane probe and
646
+ // leave retry authority to the operator.
647
+ return agent_pane_live_by_id(transport, &pane);
648
+ };
649
+ let owned = targets.iter().any(|target| {
650
+ target.pane_id == pane
651
+ && target.session.as_str() == expected_session.as_str()
652
+ && target
653
+ .window_name
654
+ .as_ref()
655
+ .is_some_and(|window| window.as_str() == expected_window)
656
+ });
657
+ owned && agent_pane_live_by_id(transport, &pane)
616
658
  }
617
659
 
618
660
  fn agent_pane_live_by_id(
@@ -405,9 +405,9 @@ pub(super) fn spawn_agent_window(
405
405
 
406
406
  // 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): route the
407
407
  // primary worker spawn through the worker shell wrapper so provider
408
- // exit leaves the pane at an interactive shell with an explicit exit
409
- // marker instead of collapsing the pane into `[exited]`. This mirrors
410
- // the leader wrapper's manual-tmux equivalence. The split path stays
408
+ // exit leaves the pane at an inert sh tail with an explicit exit marker
409
+ // instead of collapsing the pane into `[exited]`. The inert tail does
410
+ // not read pane stdin. The split path stays
411
411
  // on plain spawn_split — split panes are display overlays, not the
412
412
  // primary worker process.
413
413
  let provider_label = crate::provider::wire::command_name(provider);