@team-agent/installer 0.3.0 → 0.3.1

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.
@@ -67,6 +67,58 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
67
67
  }
68
68
  }
69
69
 
70
+ #[test]
71
+ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_collect() {
72
+ let workspace = temp_ws();
73
+ let team = workspace.join(".team").join("current");
74
+ std::fs::create_dir_all(team.join("agents")).unwrap();
75
+ std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
76
+ std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
77
+
78
+ let transport = OfflineTransport::new();
79
+ let result = quick_start_with_transport(&team, None, true, true, None, &transport);
80
+ assert!(matches!(result, Ok(QuickStartReport::Ready { .. })), "quick-start failed: {result:?}");
81
+
82
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
83
+ assert!(state_path.exists(), "quick-start .team/current must persist runtime state under project root");
84
+ assert!(
85
+ !workspace.join(".team").join(".team").join("runtime").join("state.json").exists(),
86
+ "quick-start .team/current must not create nested .team/.team runtime state"
87
+ );
88
+
89
+ for input in [&workspace, &team] {
90
+ let selected = crate::state::selector::resolve_active_team(
91
+ input,
92
+ None,
93
+ crate::state::selector::SelectorMode::RuntimeOnly,
94
+ )
95
+ .expect("status/collect selector should resolve project root");
96
+ assert_eq!(selected.run_workspace, workspace, "input={}", input.display());
97
+ assert_eq!(
98
+ selected.spec_path.as_deref().map(std::fs::canonicalize).transpose().unwrap(),
99
+ Some(std::fs::canonicalize(team.join("team.spec.yaml")).unwrap()),
100
+ "input={}",
101
+ input.display()
102
+ );
103
+ }
104
+
105
+ let status = crate::cli::cmd_status(&crate::cli::StatusArgs {
106
+ agent: None,
107
+ workspace: team.clone(),
108
+ detail: false,
109
+ summary: false,
110
+ json: true,
111
+ });
112
+ assert!(status.is_ok(), "status should normalize teamdir to project-root runtime state: {status:?}");
113
+
114
+ let collect = crate::cli::cmd_collect(&crate::cli::CollectArgs {
115
+ result_file: None,
116
+ workspace: team.clone(),
117
+ json: true,
118
+ });
119
+ assert!(collect.is_ok(), "collect should normalize teamdir to the same project-root state/spec: {collect:?}");
120
+ }
121
+
70
122
  // P0 — quick_start over an INVALID role doc (missing `provider`) must surface the REAL compile
71
123
  // error, distinct from the stub's hardcoded "no role docs found". Golden: compile_team raises
72
124
  // "missing front matter field provider" (compiler.py:_validate_role_doc), before preflight.
@@ -445,6 +445,24 @@ pub struct PermissionSummary {
445
445
  pub raw: serde_json::Value,
446
446
  }
447
447
 
448
+ /// BUG-7 (0.3.1): quick-start cannot honestly report "ready" before the workers'
449
+ /// MCP tool sets have actually loaded — provider-side schema rejections (codex
450
+ /// invalid_function_parameters etc.) happen AFTER spawn and silently disable the
451
+ /// worker. The report must therefore carry a readiness verdict so the CLI surface
452
+ /// never emits bare "ready" while worker capability is unverified or already known
453
+ /// to be degraded.
454
+ #[derive(Debug, Clone, PartialEq, Eq)]
455
+ pub enum QuickStartReadiness {
456
+ /// At least one agent already failed to materialize a live tmux window (BUG-2
457
+ /// observable). The team is *not* ready; user must inspect / restart.
458
+ Degraded { unhealthy_agents: Vec<String> },
459
+ /// All spawned agents have live windows but their MCP tool set load has NOT
460
+ /// been verified yet — provider-side schema/auth failures could still leave
461
+ /// the worker unable to call team_orchestrator tools. CLI must label this
462
+ /// `pending` / `unverified`, NOT bare `ready`.
463
+ PendingToolLoad,
464
+ }
465
+
448
466
  /// `quick_start(...)` 报告(`diagnose/quick_start.py:103` typed 版)。`Refused` 区分
449
467
  /// existing-context(需 restart 或 --fresh)与 preflight 失败。
450
468
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -454,6 +472,13 @@ pub enum QuickStartReport {
454
472
  session_name: SessionName,
455
473
  launch: Box<LaunchReport>,
456
474
  next_actions: Vec<String>,
475
+ /// BUG-7: real readiness verdict. `Ready` ⇒ the wrapper completed AND the
476
+ /// caller already verified tool-set availability; the framework itself
477
+ /// never emits this without an external observable confirming worker
478
+ /// tool calls succeeded. quick_start_with_transport always defaults to
479
+ /// [`QuickStartReadiness::PendingToolLoad`] (or `Degraded` if any agent
480
+ /// failed to spawn) so the CLI surface cannot lie about availability.
481
+ worker_readiness: QuickStartReadiness,
457
482
  },
458
483
  /// 已有 runtime state,非 --fresh → 引导用 restart(`quick_start.py:42`)。
459
484
  ExistingRuntime {
@@ -68,6 +68,34 @@
68
68
  assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
69
69
  }
70
70
 
71
+ #[test]
72
+ fn tools_contract_input_schemas_are_openai_strict_top_level_objects() {
73
+ let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
74
+ for tool in tools_contract() {
75
+ let schema = tool["inputSchema"].as_object().unwrap();
76
+ assert_eq!(schema.get("type"), Some(&json!("object")), "schema must be a top-level object: {tool}");
77
+ for key in forbidden {
78
+ assert!(
79
+ !schema.contains_key(key),
80
+ "OpenAI rejects top-level `{key}` in MCP tool schema: {tool}"
81
+ );
82
+ }
83
+ let properties = schema
84
+ .get("properties")
85
+ .and_then(Value::as_object)
86
+ .unwrap_or_else(|| panic!("schema properties must be an object: {tool}"));
87
+ for required in schema.get("required").and_then(Value::as_array).into_iter().flatten() {
88
+ let Some(name) = required.as_str() else {
89
+ panic!("required entries must be strings: {tool}");
90
+ };
91
+ assert!(
92
+ properties.contains_key(name),
93
+ "required property `{name}` must be declared in properties: {tool}"
94
+ );
95
+ }
96
+ }
97
+ }
98
+
71
99
  // ════════════════════════════════════════════════════════════════════════
72
100
  // handle_mcp — JSON-RPC routing (server.py:46-91)
73
101
  // ════════════════════════════════════════════════════════════════════════
@@ -298,13 +298,93 @@ fn tool_contract(tool: McpTool) -> Value {
298
298
  "description": description,
299
299
  "inputSchema": {
300
300
  "type": "object",
301
- "properties": {},
301
+ "properties": tool_properties(tool),
302
302
  "required": required,
303
303
  "additionalProperties": false
304
304
  }
305
305
  })
306
306
  }
307
307
 
308
+ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
309
+ let mut properties = serde_json::Map::new();
310
+ match tool {
311
+ McpTool::AssignTask => {
312
+ insert_property(&mut properties, "task", object_property("Task object to add or update."));
313
+ insert_property(&mut properties, "message", string_property("Optional message to deliver with the task."));
314
+ }
315
+ McpTool::SendMessage => {
316
+ insert_property(&mut properties, "to", string_property("Target agent id, 'leader', or '*' for broadcast."));
317
+ insert_property(&mut properties, "content", string_property("Message body."));
318
+ insert_property(&mut properties, "task_id", string_property("Optional task id to associate with the message."));
319
+ insert_property(&mut properties, "sender", string_property("Optional sender override."));
320
+ insert_property(&mut properties, "requires_ack", boolean_property("Whether the recipient should acknowledge delivery."));
321
+ insert_property(&mut properties, "scope", string_property("Optional delivery scope: team or workspace."));
322
+ }
323
+ McpTool::ReportResult => {
324
+ insert_property(&mut properties, "envelope", object_property("Optional full result envelope."));
325
+ insert_property(&mut properties, "summary", string_property("Short result summary."));
326
+ insert_property(&mut properties, "status", string_property("Result status."));
327
+ insert_property(&mut properties, "changes", array_property("Changed files or artifacts."));
328
+ insert_property(&mut properties, "tests", array_property("Tests or checks performed."));
329
+ insert_property(&mut properties, "risks", array_property("Risks or blockers."));
330
+ insert_property(&mut properties, "artifacts", array_property("Artifact references."));
331
+ insert_property(&mut properties, "next_actions", array_property("Suggested next actions."));
332
+ insert_property(&mut properties, "task_id", string_property("Optional task id override."));
333
+ insert_property(&mut properties, "agent_id", string_property("Optional reporting agent id override."));
334
+ }
335
+ McpTool::UpdateState => {
336
+ insert_property(&mut properties, "note", string_property("Note to append to team state."));
337
+ }
338
+ McpTool::GetTeamStatus | McpTool::StuckList => {}
339
+ McpTool::StopAgent => {
340
+ insert_property(&mut properties, "agent_id", string_property("Agent id to stop."));
341
+ }
342
+ McpTool::ResetAgent => {
343
+ insert_property(&mut properties, "agent_id", string_property("Agent id to reset."));
344
+ insert_property(&mut properties, "discard_session", boolean_property("Whether to discard the existing provider session."));
345
+ }
346
+ McpTool::AddAgent => {
347
+ insert_property(&mut properties, "new_agent_id", string_property("New agent id."));
348
+ insert_property(&mut properties, "role_file_path", string_property("Workspace-relative role file path."));
349
+ }
350
+ McpTool::ForkAgent => {
351
+ insert_property(&mut properties, "source_agent_id", string_property("Agent id to fork from."));
352
+ insert_property(&mut properties, "as_agent_id", string_property("Agent id for the forked worker."));
353
+ insert_property(&mut properties, "label", string_property("Optional display label."));
354
+ }
355
+ McpTool::RequestHuman => {
356
+ insert_property(&mut properties, "question", string_property("Question to ask the human."));
357
+ insert_property(&mut properties, "task_id", string_property("Optional related task id."));
358
+ insert_property(&mut properties, "agent_id", string_property("Optional requesting agent id."));
359
+ }
360
+ McpTool::StuckCancel => {
361
+ insert_property(&mut properties, "agent_id", string_property("Agent id whose stuck alerts should be suppressed."));
362
+ insert_property(&mut properties, "alert_type", string_property("Alert type to suppress, or all."));
363
+ }
364
+ }
365
+ properties
366
+ }
367
+
368
+ fn insert_property(properties: &mut serde_json::Map<String, Value>, name: &str, schema: Value) {
369
+ properties.insert(name.to_string(), schema);
370
+ }
371
+
372
+ fn string_property(description: &str) -> Value {
373
+ serde_json::json!({"type": "string", "description": description})
374
+ }
375
+
376
+ fn boolean_property(description: &str) -> Value {
377
+ serde_json::json!({"type": "boolean", "description": description})
378
+ }
379
+
380
+ fn object_property(description: &str) -> Value {
381
+ serde_json::json!({"type": "object", "description": description, "additionalProperties": true})
382
+ }
383
+
384
+ fn array_property(description: &str) -> Value {
385
+ serde_json::json!({"type": "array", "description": description, "items": {"type": "object", "additionalProperties": true}})
386
+ }
387
+
308
388
  pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args: &Value) -> ToolResult {
309
389
  match tool {
310
390
  McpTool::AssignTask => tools.assign_task(args.get("task").unwrap_or(args), args.get("message").and_then(Value::as_str)),
@@ -7,7 +7,7 @@ use rusqlite::{params, OptionalExtension};
7
7
 
8
8
  use crate::event_log::EventLog;
9
9
  use crate::message_store::MessageStore;
10
- use crate::model::enums::Provider;
10
+ use crate::model::enums::{PaneLiveness, Provider};
11
11
  use crate::model::ids::TeamKey;
12
12
  use crate::transport::{InjectPayload, Key, PaneId, SessionName, Target, Transport, WindowName};
13
13
 
@@ -135,6 +135,61 @@ pub fn deliver_pending_message(
135
135
  channel: None,
136
136
  });
137
137
  };
138
+ if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
139
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
140
+ event_log.write(
141
+ "leader_receiver.delivery_blocked",
142
+ serde_json::json!({
143
+ "message_id": message_id,
144
+ "sender": message.sender,
145
+ "reason": "leader_not_attached",
146
+ "channel": "rebind_required",
147
+ "action": "run team-agent claim-leader or team-agent takeover",
148
+ "error": "leader_receiver.tmux_socket is not a canonical full socket path",
149
+ }),
150
+ )?;
151
+ return Ok(DeliveryOutcome {
152
+ ok: false,
153
+ status: DeliveryStatus::Refused,
154
+ message_status: MessageStatusShadow("failed".to_string()),
155
+ message_id: Some(message_id.to_string()),
156
+ verification: Some(
157
+ "run team-agent claim-leader or team-agent takeover".to_string(),
158
+ ),
159
+ stage: None,
160
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
161
+ channel: Some("rebind_required".to_string()),
162
+ });
163
+ }
164
+ let delivery_transport =
165
+ delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
166
+ let transport = delivery_transport.as_transport();
167
+ // Do not inject queued leader messages into a synthetic "leader" window.
168
+ if message.recipient == "leader" && !leader_receiver_pane_is_usable(transport, state) {
169
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
170
+ event_log.write(
171
+ "leader_receiver.delivery_blocked",
172
+ serde_json::json!({
173
+ "message_id": message_id,
174
+ "sender": message.sender,
175
+ "reason": "leader_not_attached",
176
+ "channel": "rebind_required",
177
+ "action": "run team-agent claim-leader or team-agent takeover",
178
+ }),
179
+ )?;
180
+ return Ok(DeliveryOutcome {
181
+ ok: false,
182
+ status: DeliveryStatus::Refused,
183
+ message_status: MessageStatusShadow("failed".to_string()),
184
+ message_id: Some(message_id.to_string()),
185
+ verification: Some(
186
+ "run team-agent claim-leader or team-agent takeover".to_string(),
187
+ ),
188
+ stage: None,
189
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
190
+ channel: Some("rebind_required".to_string()),
191
+ });
192
+ }
138
193
  let target = resolve_inject_target(state, &message.recipient);
139
194
  // Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
140
195
  // menu is NOT provider delivery — the menu consumes the Enter and the task text
@@ -170,12 +225,40 @@ pub fn deliver_pending_message(
170
225
  &message.content,
171
226
  message_id,
172
227
  );
173
- transport.inject(
228
+ if let Err(error) = transport.inject(
174
229
  &target,
175
230
  &InjectPayload::Text(rendered),
176
231
  Key::Enter,
177
232
  true,
178
- )?;
233
+ ) {
234
+ if message.recipient == "leader" {
235
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
236
+ event_log.write(
237
+ "leader_receiver.delivery_blocked",
238
+ serde_json::json!({
239
+ "message_id": message_id,
240
+ "sender": message.sender,
241
+ "reason": "leader_not_attached",
242
+ "channel": "rebind_required",
243
+ "action": "run team-agent claim-leader or team-agent takeover",
244
+ "error": error.to_string(),
245
+ }),
246
+ )?;
247
+ return Ok(DeliveryOutcome {
248
+ ok: false,
249
+ status: DeliveryStatus::Refused,
250
+ message_status: MessageStatusShadow("failed".to_string()),
251
+ message_id: Some(message_id.to_string()),
252
+ verification: Some(
253
+ "run team-agent claim-leader or team-agent takeover".to_string(),
254
+ ),
255
+ stage: None,
256
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
257
+ channel: Some("rebind_required".to_string()),
258
+ });
259
+ }
260
+ return Err(error.into());
261
+ }
179
262
  store.mark(message_id, "delivered", None)?;
180
263
  event_log.write(
181
264
  "message.delivered",
@@ -220,7 +303,15 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
220
303
  /// else a session-qualified `SessionWindow` (state.session_name + the agent's window, defaulting to the
221
304
  /// id). NEVER the bare agent-id as a pane — a clientless coordinator cannot resolve that
222
305
  /// ("can't find pane: w1", rt-host-a loop #3). Mirrors `coordinator/tick.rs::capture_target`.
306
+ ///
307
+ /// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
308
+ /// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
223
309
  fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
310
+ if recipient == "leader" {
311
+ if let Some(pane_id) = leader_receiver_pane_id(state) {
312
+ return Target::Pane(PaneId::new(pane_id));
313
+ }
314
+ }
224
315
  let agent = state.get("agents").and_then(|a| a.get(recipient));
225
316
  if let Some(pane_id) = agent
226
317
  .and_then(|a| a.get("pane_id"))
@@ -244,6 +335,116 @@ fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
244
335
  }
245
336
  }
246
337
 
338
+ /// Read the bound leader pane id off the projected or team-scoped runtime state.
339
+ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
340
+ leader_receiver_pane_id_in_state(state)
341
+ .or_else(|| active_team_entry(state).and_then(leader_receiver_pane_id_in_state))
342
+ .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
343
+ }
344
+
345
+ fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json::Value) -> bool {
346
+ let Some(pane_id) = leader_receiver_pane_id(state) else {
347
+ return false;
348
+ };
349
+ if transport
350
+ .list_targets()
351
+ .unwrap_or_default()
352
+ .iter()
353
+ .any(|target| target.pane_id.as_str() == pane_id)
354
+ {
355
+ return true;
356
+ }
357
+ !matches!(transport.liveness(&PaneId::new(pane_id)), Ok(PaneLiveness::Dead))
358
+ }
359
+
360
+ enum DeliveryTransport<'a> {
361
+ Borrowed(&'a dyn Transport),
362
+ Owned(crate::tmux_backend::TmuxBackend),
363
+ }
364
+
365
+ impl<'a> DeliveryTransport<'a> {
366
+ fn as_transport(&'a self) -> &'a dyn Transport {
367
+ match self {
368
+ Self::Borrowed(transport) => *transport,
369
+ Self::Owned(transport) => transport,
370
+ }
371
+ }
372
+ }
373
+
374
+ fn delivery_transport_for_recipient<'a>(
375
+ workspace: &Path,
376
+ product_transport: &'a dyn Transport,
377
+ state: &serde_json::Value,
378
+ recipient: &str,
379
+ ) -> DeliveryTransport<'a> {
380
+ if recipient != "leader" {
381
+ return DeliveryTransport::Borrowed(product_transport);
382
+ }
383
+ let Some(socket) = leader_receiver_tmux_socket(state) else {
384
+ return DeliveryTransport::Borrowed(product_transport);
385
+ };
386
+ if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
387
+ DeliveryTransport::Borrowed(product_transport)
388
+ } else {
389
+ DeliveryTransport::Owned(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket))
390
+ }
391
+ }
392
+
393
+ fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
394
+ ["leader_receiver", "team_owner"].into_iter().find_map(|key| {
395
+ state
396
+ .get(key)
397
+ .and_then(|r| r.get("pane_id"))
398
+ .and_then(serde_json::Value::as_str)
399
+ .filter(|s| !s.is_empty())
400
+ })
401
+ }
402
+
403
+ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
404
+ leader_receiver_field(state, "tmux_socket")
405
+ }
406
+
407
+ fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
408
+ leader_receiver_tmux_socket(state)
409
+ .is_some_and(|socket| {
410
+ socket != "default" && !std::path::Path::new(socket).is_absolute()
411
+ })
412
+ }
413
+
414
+ fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
415
+ leader_receiver_field_in_state(state, field)
416
+ .or_else(|| active_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
417
+ .or_else(|| only_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
418
+ }
419
+
420
+ fn leader_receiver_field_in_state<'a>(
421
+ state: &'a serde_json::Value,
422
+ field: &str,
423
+ ) -> Option<&'a str> {
424
+ state
425
+ .get("leader_receiver")
426
+ .and_then(|receiver| receiver.get(field))
427
+ .and_then(serde_json::Value::as_str)
428
+ .filter(|value| !value.is_empty())
429
+ }
430
+
431
+ fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
432
+ let team = state.get("active_team_key").and_then(serde_json::Value::as_str)?;
433
+ state
434
+ .get("teams")
435
+ .and_then(serde_json::Value::as_object)
436
+ .and_then(|teams| teams.get(team))
437
+ }
438
+
439
+ fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
440
+ let teams = state.get("teams").and_then(serde_json::Value::as_object)?;
441
+ if teams.len() == 1 {
442
+ teams.values().next()
443
+ } else {
444
+ None
445
+ }
446
+ }
447
+
247
448
  /// `_deliver_pending_messages` (`delivery.py:484`):扫 pending 队列逐条投递;busy 收件人写
248
449
  /// `send.deferred_busy` 跳过 (**不丢**,card §131)。返回投递的 message_id 列表。
249
450
  pub fn deliver_pending_messages(
@@ -10,7 +10,7 @@ use crate::model::ids::{OwnerEpoch, TaskId};
10
10
  use crate::transport::Transport;
11
11
 
12
12
  use super::helpers::MessageStatusShadow;
13
- use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
+ use super::{DeliveryOutcome, DeliveryStatus, MessagingError};
14
14
 
15
15
  /// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
16
16
  /// caller(send_message(to=leader) / report_result / request_human / idle reminder /
@@ -105,40 +105,6 @@ pub fn send_to_leader_receiver(
105
105
  "result_id": result_id,
106
106
  }),
107
107
  )?;
108
- // I-4: unbound leader pane → rebind_required (not the legacy diagnostic success).
109
- // The row IS persisted (caller / rebind audit / future replay need the message_id),
110
- // but marked `failed` so `deliver_pending_messages` does NOT pick it up — pane stays
111
- // untouched, ok=false, channel=rebind_required. #231 auto-reclaim's
112
- // requeue_after_claim_leader flips this row back to `accepted` after rebind, and
113
- // deliver_pending replays it through the same pipeline (same message_id, exactly once).
114
- let pane_attached = leader_pane_id(state)
115
- .filter(|pane_id| leader_pane_is_live(workspace, pane_id))
116
- .is_some();
117
- if !pane_attached {
118
- let _ = store.mark(&message_id, "failed", Some("leader_not_attached"));
119
- event_log.write(
120
- "leader_receiver.delivery_blocked",
121
- serde_json::json!({
122
- "message_id": message_id,
123
- "sender": sender,
124
- "leader_id": leader_id,
125
- "owner_team_id": owner_team,
126
- "reason": "leader_not_attached",
127
- "channel": "rebind_required",
128
- "action": "run team-agent claim-leader or team-agent takeover",
129
- }),
130
- )?;
131
- return Ok(DeliveryOutcome {
132
- ok: false,
133
- status: DeliveryStatus::Blocked,
134
- message_status: MessageStatusShadow("blocked".to_string()),
135
- message_id: Some(message_id),
136
- verification: None,
137
- stage: None,
138
- reason: Some(DeliveryRefusal::LeaderNotAttached),
139
- channel: Some("rebind_required".to_string()),
140
- });
141
- }
142
108
  event_log.write(
143
109
  "leader_receiver.queued",
144
110
  serde_json::json!({
@@ -221,6 +187,15 @@ pub fn claim_leader_receiver(
221
187
  copy_candidate_field(receiver, candidate, "pane_id");
222
188
  copy_candidate_field(receiver, candidate, "provider");
223
189
  copy_candidate_field(receiver, candidate, "leader_session_uuid");
190
+ if let Some(socket) = candidate
191
+ .get("tmux_socket")
192
+ .and_then(Value::as_str)
193
+ .filter(|socket| std::path::Path::new(socket).is_absolute())
194
+ .map(str::to_string)
195
+ .or_else(crate::tmux_backend::socket_name_from_tmux_env)
196
+ {
197
+ receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
198
+ }
224
199
  }
225
200
  crate::state::persist::save_runtime_state(workspace, state)?;
226
201
  event_log.write(
@@ -310,10 +285,17 @@ fn leader_session_uuid(state: &Value) -> Option<&str> {
310
285
 
311
286
  pub(crate) fn leader_pane_bound_but_not_live(workspace: &Path, state: &Value) -> bool {
312
287
  leader_pane_id(state)
313
- .is_some_and(|pane_id| !leader_pane_is_live(workspace, pane_id))
288
+ .is_some_and(|pane_id| !leader_pane_is_live(workspace, state, pane_id))
314
289
  }
315
290
 
316
- fn leader_pane_is_live(workspace: &Path, pane_id: &str) -> bool {
291
+ fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
292
+ if let Some(socket) = leader_tmux_socket(state) {
293
+ return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket)
294
+ .list_targets()
295
+ .unwrap_or_default()
296
+ .iter()
297
+ .any(|target| target.pane_id.as_str() == pane_id);
298
+ }
317
299
  let mut targets = crate::tmux_backend::TmuxBackend::for_workspace(workspace)
318
300
  .list_targets()
319
301
  .unwrap_or_default();
@@ -329,6 +311,13 @@ fn leader_pane_id(state: &Value) -> Option<&str> {
329
311
  leader_record_field(state, "pane_id").and_then(Value::as_str)
330
312
  }
331
313
 
314
+ fn leader_tmux_socket(state: &Value) -> Option<&str> {
315
+ leader_record_field(state, "tmux_socket")
316
+ .and_then(Value::as_str)
317
+ .filter(|socket| !socket.is_empty())
318
+ .filter(|socket| std::path::Path::new(socket).is_absolute())
319
+ }
320
+
332
321
  fn copy_candidate_field(
333
322
  out: &mut serde_json::Map<String, Value>,
334
323
  candidate: &Value,
@@ -394,7 +394,7 @@ pub fn report_result(
394
394
  let content = format_report_result_notification(&result_id, task_id, agent_id, status, envelope);
395
395
  let state = crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
396
396
  let event_log = EventLog::new(workspace);
397
- let outcome = super::leader_receiver::send_to_leader_receiver(
397
+ let mut outcome = super::leader_receiver::send_to_leader_receiver(
398
398
  workspace,
399
399
  &state,
400
400
  "leader",
@@ -405,10 +405,26 @@ pub fn report_result(
405
405
  Some(&result_id),
406
406
  &event_log,
407
407
  )?;
408
+ if matches!(outcome.status, crate::messaging::DeliveryStatus::Queued) {
409
+ if let Some(message_id) = outcome.message_id.clone() {
410
+ let store = MessageStore::open(workspace)?;
411
+ let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
412
+ outcome = super::delivery::deliver_pending_message(
413
+ workspace,
414
+ &store,
415
+ &transport,
416
+ &message_id,
417
+ &event_log,
418
+ &state,
419
+ )?;
420
+ }
421
+ }
408
422
  let leader_notified = outcome.ok;
409
423
  let notification_status_wire = if outcome.ok {
410
424
  "delivered"
411
- } else if matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked) {
425
+ } else if outcome.channel.as_deref() == Some("rebind_required")
426
+ || matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked)
427
+ {
412
428
  "rebind_required"
413
429
  } else {
414
430
  "refused"
@@ -8,7 +8,7 @@ use crate::model::enums::PaneLiveness;
8
8
  use crate::transport::{PaneId, Transport};
9
9
 
10
10
  use super::helpers::{status_wire, MessageStatusShadow};
11
- use super::leader_receiver::{leader_pane_bound_but_not_live, send_to_leader_receiver};
11
+ use super::leader_receiver::send_to_leader_receiver;
12
12
  use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
13
 
14
14
  /// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
@@ -86,25 +86,9 @@ pub fn send_message(
86
86
  .map(|team| crate::state::projection::project_top_level_view(&raw_state, team.as_str()))
87
87
  .unwrap_or_else(|| raw_state.clone());
88
88
  backfill_leader_binding_for_delivery_view(&mut state, &raw_state);
89
- let target_is_leader = matches!(target, MessageTarget::Single(target) if target == "leader");
90
- if target_is_leader
91
- && sender_is_leader(&state, &opts.sender)
92
- && leader_pane_bound_but_not_live(workspace, &state)
93
- {
94
- event_log.write(
95
- "leader_receiver.delivery_blocked",
96
- serde_json::json!({
97
- "sender": opts.sender,
98
- "reason": "leader_not_attached",
99
- "channel": "rebind_required",
100
- "action": "run team-agent claim-leader or team-agent takeover",
101
- }),
102
- )?;
103
- return Ok(rebind_required_outcome(None));
104
- }
105
89
  let recipient = match target {
106
90
  MessageTarget::Single(target) if target == "leader" => {
107
- return send_to_leader_receiver(
91
+ let outcome = send_to_leader_receiver(
108
92
  workspace,
109
93
  &state,
110
94
  "leader",
@@ -114,7 +98,19 @@ pub fn send_message(
114
98
  opts.requires_ack,
115
99
  None,
116
100
  &event_log,
117
- );
101
+ )?;
102
+ if matches!(outcome.status, DeliveryStatus::Queued) && owner_pane_is_dead(&state) {
103
+ if let Some(message_id) = outcome.message_id.clone() {
104
+ let team_key = owner_gate_hint_team_key(&state);
105
+ if !explicit_claim_applied(workspace, &team_key, "") {
106
+ return Ok(rebind_required_outcome_with_verification(
107
+ Some(message_id),
108
+ format!("team-agent claim-leader --team {team_key}"),
109
+ ));
110
+ }
111
+ }
112
+ }
113
+ return Ok(outcome);
118
114
  }
119
115
  MessageTarget::Single(target) if target.is_empty() => {
120
116
  return Ok(refused_outcome(DeliveryRefusal::UnknownRecipient));