@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +178 -51
- package/crates/team-agent/src/cli/mod.rs +83 -17
- package/crates/team-agent/src/coordinator/health.rs +121 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +300 -24
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +204 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/results.rs +18 -2
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +58 -6
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -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,
|
|
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
|
|
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::
|
|
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
|
-
|
|
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));
|