@team-agent/installer 0.5.51 → 0.5.53
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/adapters.rs +18 -2
- package/crates/team-agent/src/cli/emit.rs +17 -0
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/named_address.rs +35 -67
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +526 -73
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
- package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
- package/crates/team-agent/src/lifecycle/restart.rs +25 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +18 -4
- package/crates/team-agent/src/mcp_server/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/wire.rs +30 -7
- package/crates/team-agent/src/messaging/delivery.rs +126 -147
- package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
- package/crates/team-agent/src/messaging/mod.rs +5 -0
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +321 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +282 -20
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository.rs +36 -12
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +8 -3
|
@@ -33,6 +33,7 @@ pub fn tools_contract() -> Vec<Value> {
|
|
|
33
33
|
McpTool::StopAgent,
|
|
34
34
|
McpTool::ResetAgent,
|
|
35
35
|
McpTool::AddAgent,
|
|
36
|
+
McpTool::CloneAgent,
|
|
36
37
|
McpTool::ForkAgent,
|
|
37
38
|
McpTool::RequestHuman,
|
|
38
39
|
McpTool::StuckList,
|
|
@@ -378,6 +379,7 @@ fn tool_contract(tool: McpTool) -> Value {
|
|
|
378
379
|
McpTool::StopAgent => ("Stop a running worker.", vec!["agent_id"]),
|
|
379
380
|
McpTool::ResetAgent => ("Reset one worker to a fresh session.", vec!["agent_id", "discard_session"]),
|
|
380
381
|
McpTool::AddAgent => ("Add a first-class worker from a role file.", vec!["new_agent_id", "role_file_path"]),
|
|
382
|
+
McpTool::CloneAgent => ("Clone a worker role into a fresh provider session.", vec!["source_agent_id", "as_agent_id"]),
|
|
381
383
|
McpTool::ForkAgent => ("Fork a running worker.", vec!["source_agent_id", "as_agent_id"]),
|
|
382
384
|
McpTool::RequestHuman => ("Ask the leader or user for human input.", vec!["question"]),
|
|
383
385
|
McpTool::StuckList => ("List manually suppressed idle alerts.", Vec::new()),
|
|
@@ -522,6 +524,23 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
|
|
|
522
524
|
string_property("Optional display label."),
|
|
523
525
|
);
|
|
524
526
|
}
|
|
527
|
+
McpTool::CloneAgent => {
|
|
528
|
+
insert_property(
|
|
529
|
+
&mut properties,
|
|
530
|
+
"source_agent_id",
|
|
531
|
+
string_property("Agent id to clone from."),
|
|
532
|
+
);
|
|
533
|
+
insert_property(
|
|
534
|
+
&mut properties,
|
|
535
|
+
"as_agent_id",
|
|
536
|
+
string_property("Agent id for the cloned worker."),
|
|
537
|
+
);
|
|
538
|
+
insert_property(
|
|
539
|
+
&mut properties,
|
|
540
|
+
"label",
|
|
541
|
+
string_property("Optional display label."),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
525
544
|
McpTool::RequestHuman => {
|
|
526
545
|
insert_property(
|
|
527
546
|
&mut properties,
|
|
@@ -591,13 +610,7 @@ pub(crate) fn dispatch_tool(
|
|
|
591
610
|
McpTool::SendMessage => {
|
|
592
611
|
let target = message_target_from_value(args.get("to"));
|
|
593
612
|
let content = args.get("content").and_then(Value::as_str).unwrap_or("");
|
|
594
|
-
let outcome = tools.send_message(
|
|
595
|
-
&target,
|
|
596
|
-
content,
|
|
597
|
-
None,
|
|
598
|
-
None,
|
|
599
|
-
None,
|
|
600
|
-
)?;
|
|
613
|
+
let outcome = tools.send_message(&target, content, None, None, None)?;
|
|
601
614
|
match outcome {
|
|
602
615
|
SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
|
|
603
616
|
fields: object_fields(outcome.to_value()),
|
|
@@ -668,6 +681,15 @@ pub(crate) fn dispatch_tool(
|
|
|
668
681
|
.unwrap_or(""),
|
|
669
682
|
args.get("label").and_then(Value::as_str),
|
|
670
683
|
),
|
|
684
|
+
McpTool::CloneAgent => tools.clone_agent(
|
|
685
|
+
args.get("source_agent_id")
|
|
686
|
+
.and_then(Value::as_str)
|
|
687
|
+
.unwrap_or(""),
|
|
688
|
+
args.get("as_agent_id")
|
|
689
|
+
.and_then(Value::as_str)
|
|
690
|
+
.unwrap_or(""),
|
|
691
|
+
args.get("label").and_then(Value::as_str),
|
|
692
|
+
),
|
|
671
693
|
McpTool::RequestHuman => tools.request_human(
|
|
672
694
|
args.get("question").and_then(Value::as_str).unwrap_or(""),
|
|
673
695
|
args.get("task_id").and_then(Value::as_str),
|
|
@@ -695,6 +717,7 @@ fn scope_ceiling_tool(tool: McpTool) -> bool {
|
|
|
695
717
|
| McpTool::GetTeamStatus
|
|
696
718
|
| McpTool::StopAgent
|
|
697
719
|
| McpTool::ResetAgent
|
|
720
|
+
| McpTool::CloneAgent
|
|
698
721
|
| McpTool::ForkAgent
|
|
699
722
|
)
|
|
700
723
|
}
|
|
@@ -223,16 +223,41 @@ pub fn deliver_pending_message(
|
|
|
223
223
|
return observe_pending_leader_acceptance(store, event_log, state, message_id);
|
|
224
224
|
}
|
|
225
225
|
if message.recipient == "leader" && message.status == "queued_until_leader_attach" {
|
|
226
|
-
|
|
226
|
+
let Some(receiver) = leader_receiver_value(state) else {
|
|
227
227
|
return Ok(leader_acceptance_pending_outcome(
|
|
228
228
|
message_id,
|
|
229
229
|
"queued_until_leader_attach",
|
|
230
230
|
));
|
|
231
|
-
}
|
|
231
|
+
};
|
|
232
|
+
let channel_transport =
|
|
233
|
+
delivery_transport_for_recipient(workspace, transport, state, "leader");
|
|
234
|
+
let resolution = crate::messaging::resolve_live_leader_channel(
|
|
235
|
+
workspace,
|
|
236
|
+
receiver,
|
|
237
|
+
channel_transport.as_transport(),
|
|
238
|
+
);
|
|
239
|
+
let crate::messaging::LeaderChannelResolution::Live(channel) = resolution else {
|
|
240
|
+
return Ok(leader_acceptance_pending_outcome(
|
|
241
|
+
message_id,
|
|
242
|
+
"queued_until_leader_attach",
|
|
243
|
+
));
|
|
244
|
+
};
|
|
245
|
+
let metadata_drift = match &channel {
|
|
246
|
+
crate::messaging::LiveLeaderChannel::DirectTmux(channel) => {
|
|
247
|
+
channel.metadata_drift.clone()
|
|
248
|
+
}
|
|
249
|
+
crate::messaging::LiveLeaderChannel::CodexAppServer(_) => Vec::new(),
|
|
250
|
+
};
|
|
232
251
|
store.mark(message_id, "accepted", None)?;
|
|
233
252
|
event_log.write(
|
|
234
253
|
"leader_receiver.mailbox_requeued",
|
|
235
|
-
serde_json::json!({
|
|
254
|
+
serde_json::json!({
|
|
255
|
+
"message_id": message_id,
|
|
256
|
+
"channel_live": true,
|
|
257
|
+
"metadata_drift": metadata_drift,
|
|
258
|
+
"requeue_trigger": "delivery_revalidation",
|
|
259
|
+
"revalidated_at": chrono::Utc::now().to_rfc3339(),
|
|
260
|
+
}),
|
|
236
261
|
)?;
|
|
237
262
|
}
|
|
238
263
|
let attempt = if store.claim_for_delivery(message_id)? {
|
|
@@ -252,88 +277,108 @@ pub fn deliver_pending_message(
|
|
|
252
277
|
});
|
|
253
278
|
};
|
|
254
279
|
if message.recipient == "leader" {
|
|
255
|
-
|
|
256
|
-
|
|
280
|
+
let Some(receiver) = leader_receiver_value(state) else {
|
|
281
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
282
|
+
event_log.write(
|
|
283
|
+
"leader_receiver.delivery_blocked",
|
|
284
|
+
serde_json::json!({
|
|
285
|
+
"message_id": message_id,
|
|
286
|
+
"sender": message.sender,
|
|
287
|
+
"reason": "leader_receiver_not_attached",
|
|
288
|
+
"error": "leader_not_attached",
|
|
289
|
+
"channel_reason": "receiver_missing",
|
|
290
|
+
"channel": "rebind_required",
|
|
291
|
+
"action": "run team-agent attach-leader or team-agent takeover",
|
|
292
|
+
"status": "unbound",
|
|
293
|
+
}),
|
|
294
|
+
)?;
|
|
295
|
+
return Ok(DeliveryOutcome {
|
|
296
|
+
ok: false,
|
|
297
|
+
status: DeliveryStatus::Refused,
|
|
298
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
299
|
+
message_id: Some(message_id.to_string()),
|
|
300
|
+
verification: Some(
|
|
301
|
+
"run team-agent attach-leader or team-agent takeover".to_string(),
|
|
302
|
+
),
|
|
303
|
+
stage: None,
|
|
304
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
305
|
+
channel: Some("rebind_required".to_string()),
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
if let Some(outcome) = leader_receiver_transport_conflict_outcome(
|
|
309
|
+
store,
|
|
310
|
+
event_log,
|
|
311
|
+
message_id,
|
|
312
|
+
receiver,
|
|
313
|
+
&message.sender,
|
|
314
|
+
)? {
|
|
315
|
+
return Ok(outcome);
|
|
316
|
+
}
|
|
317
|
+
let channel_transport =
|
|
318
|
+
delivery_transport_for_recipient(workspace, transport, state, "leader");
|
|
319
|
+
match crate::messaging::resolve_live_leader_channel(
|
|
320
|
+
workspace,
|
|
321
|
+
receiver,
|
|
322
|
+
channel_transport.as_transport(),
|
|
323
|
+
) {
|
|
324
|
+
crate::messaging::LeaderChannelResolution::Live(_) => {}
|
|
325
|
+
crate::messaging::LeaderChannelResolution::ProbeFailed(error) => {
|
|
326
|
+
store.mark(message_id, "target_resolved", Some(&error))?;
|
|
327
|
+
return Ok(DeliveryOutcome {
|
|
328
|
+
ok: false,
|
|
329
|
+
status: DeliveryStatus::Degraded,
|
|
330
|
+
message_status: MessageStatusShadow("target_resolved".to_string()),
|
|
331
|
+
message_id: Some(message_id.to_string()),
|
|
332
|
+
verification: Some(error),
|
|
333
|
+
stage: None,
|
|
334
|
+
reason: None,
|
|
335
|
+
channel: Some("rebind_required".to_string()),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
crate::messaging::LeaderChannelResolution::Unbound(reason) => {
|
|
339
|
+
let receiver_status = receiver
|
|
340
|
+
.get("status")
|
|
341
|
+
.and_then(serde_json::Value::as_str)
|
|
342
|
+
.unwrap_or("unbound");
|
|
343
|
+
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
344
|
+
event_log.write(
|
|
345
|
+
"leader_receiver.delivery_blocked",
|
|
346
|
+
serde_json::json!({
|
|
347
|
+
"message_id": message_id,
|
|
348
|
+
"sender": message.sender,
|
|
349
|
+
"reason": "leader_receiver_not_attached",
|
|
350
|
+
"error": "leader_not_attached",
|
|
351
|
+
"channel_reason": format!("{reason:?}"),
|
|
352
|
+
"channel": "rebind_required",
|
|
353
|
+
"action": "run team-agent attach-leader or team-agent takeover",
|
|
354
|
+
"status": receiver_status,
|
|
355
|
+
}),
|
|
356
|
+
)?;
|
|
357
|
+
return Ok(DeliveryOutcome {
|
|
358
|
+
ok: false,
|
|
359
|
+
status: DeliveryStatus::Refused,
|
|
360
|
+
message_status: MessageStatusShadow("failed".to_string()),
|
|
361
|
+
message_id: Some(message_id.to_string()),
|
|
362
|
+
verification: Some(
|
|
363
|
+
"run team-agent attach-leader or team-agent takeover".to_string(),
|
|
364
|
+
),
|
|
365
|
+
stage: None,
|
|
366
|
+
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
367
|
+
channel: Some("rebind_required".to_string()),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
372
|
+
return deliver_leader_via_app_server(
|
|
257
373
|
store,
|
|
258
374
|
event_log,
|
|
375
|
+
state,
|
|
259
376
|
message_id,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
)
|
|
263
|
-
return Ok(outcome);
|
|
264
|
-
}
|
|
265
|
-
// 0.5.4 gate054 cross-team leak fix: a `leader_receiver.status`
|
|
266
|
-
// other than `"attached"` (restart demotes killed-session receivers
|
|
267
|
-
// to `"rebind_required"` — lifecycle/restart/rebuild.rs:1311) MUST
|
|
268
|
-
// refuse delivery before any pane injection or cross-server pane-id
|
|
269
|
-
// probing. Without this gate, a stale receiver on team A's socket
|
|
270
|
-
// could otherwise fall back to the same pane id on the main tmux
|
|
271
|
-
// server (delivery.rs:1150-1201) and inject A's leader traffic into
|
|
272
|
-
// team B's leader pane. See .team/artifacts/0.5.4-gate-crossteam-notify-triage.md.
|
|
273
|
-
if let Some(status) = leader_receiver_status(state) {
|
|
274
|
-
if status != "attached" {
|
|
275
|
-
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
276
|
-
event_log.write(
|
|
277
|
-
"leader_receiver.delivery_blocked",
|
|
278
|
-
serde_json::json!({
|
|
279
|
-
"message_id": message_id,
|
|
280
|
-
"sender": message.sender,
|
|
281
|
-
"reason": "leader_receiver_not_attached",
|
|
282
|
-
"channel": "rebind_required",
|
|
283
|
-
"action": "run team-agent attach-leader or team-agent takeover",
|
|
284
|
-
"status": status,
|
|
285
|
-
}),
|
|
286
|
-
)?;
|
|
287
|
-
return Ok(DeliveryOutcome {
|
|
288
|
-
ok: false,
|
|
289
|
-
status: DeliveryStatus::Refused,
|
|
290
|
-
message_status: MessageStatusShadow("failed".to_string()),
|
|
291
|
-
message_id: Some(message_id.to_string()),
|
|
292
|
-
verification: Some(
|
|
293
|
-
"run team-agent attach-leader or team-agent takeover".to_string(),
|
|
294
|
-
),
|
|
295
|
-
stage: None,
|
|
296
|
-
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
297
|
-
channel: Some("rebind_required".to_string()),
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
302
|
-
return deliver_leader_via_app_server(
|
|
303
|
-
store,
|
|
304
|
-
event_log,
|
|
305
|
-
state,
|
|
306
|
-
message_id,
|
|
307
|
-
&message,
|
|
308
|
-
canonical_owner_team_id.as_deref(),
|
|
309
|
-
);
|
|
310
|
-
}
|
|
377
|
+
&message,
|
|
378
|
+
canonical_owner_team_id.as_deref(),
|
|
379
|
+
);
|
|
311
380
|
}
|
|
312
381
|
}
|
|
313
|
-
if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
|
|
314
|
-
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
315
|
-
event_log.write(
|
|
316
|
-
"leader_receiver.delivery_blocked",
|
|
317
|
-
serde_json::json!({
|
|
318
|
-
"message_id": message_id,
|
|
319
|
-
"sender": message.sender,
|
|
320
|
-
"reason": "leader_not_attached",
|
|
321
|
-
"channel": "rebind_required",
|
|
322
|
-
"action": "run team-agent claim-leader or team-agent takeover",
|
|
323
|
-
"error": "leader_receiver.tmux_socket is not a canonical full socket path",
|
|
324
|
-
}),
|
|
325
|
-
)?;
|
|
326
|
-
return Ok(DeliveryOutcome {
|
|
327
|
-
ok: false,
|
|
328
|
-
status: DeliveryStatus::Refused,
|
|
329
|
-
message_status: MessageStatusShadow("failed".to_string()),
|
|
330
|
-
message_id: Some(message_id.to_string()),
|
|
331
|
-
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
332
|
-
stage: None,
|
|
333
|
-
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
334
|
-
channel: Some("rebind_required".to_string()),
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
382
|
let delivery_transport =
|
|
338
383
|
delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
|
|
339
384
|
let transport = delivery_transport.as_transport();
|
|
@@ -369,32 +414,6 @@ pub fn deliver_pending_message(
|
|
|
369
414
|
});
|
|
370
415
|
}
|
|
371
416
|
};
|
|
372
|
-
// Do not inject queued leader messages into a synthetic "leader" window.
|
|
373
|
-
if message.recipient == "leader"
|
|
374
|
-
&& !leader_receiver_pane_is_usable(transport, state, &live_targets)
|
|
375
|
-
{
|
|
376
|
-
store.mark(message_id, "failed", Some("leader_not_attached"))?;
|
|
377
|
-
event_log.write(
|
|
378
|
-
"leader_receiver.delivery_blocked",
|
|
379
|
-
serde_json::json!({
|
|
380
|
-
"message_id": message_id,
|
|
381
|
-
"sender": message.sender,
|
|
382
|
-
"reason": "leader_not_attached",
|
|
383
|
-
"channel": "rebind_required",
|
|
384
|
-
"action": "run team-agent claim-leader or team-agent takeover",
|
|
385
|
-
}),
|
|
386
|
-
)?;
|
|
387
|
-
return Ok(DeliveryOutcome {
|
|
388
|
-
ok: false,
|
|
389
|
-
status: DeliveryStatus::Refused,
|
|
390
|
-
message_status: MessageStatusShadow("failed".to_string()),
|
|
391
|
-
message_id: Some(message_id.to_string()),
|
|
392
|
-
verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
|
|
393
|
-
stage: None,
|
|
394
|
-
reason: Some(DeliveryRefusal::LeaderNotAttached),
|
|
395
|
-
channel: Some("rebind_required".to_string()),
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
417
|
let resolved = resolve_inject_target(state, &message.recipient, transport, &live_targets);
|
|
399
418
|
if let Some(stale) = resolved.stale_binding.as_ref() {
|
|
400
419
|
event_log.write(
|
|
@@ -1533,32 +1552,6 @@ fn leader_receiver_pane_binding(state: &serde_json::Value) -> Option<PaneSocketB
|
|
|
1533
1552
|
.or_else(|| only_team_entry(state).and_then(leader_receiver_pane_binding_in_state))
|
|
1534
1553
|
}
|
|
1535
1554
|
|
|
1536
|
-
/// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
|
|
1537
|
-
/// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
|
|
1538
|
-
///
|
|
1539
|
-
/// **0.3.24 excision (U1-A real-machine RED v2)**: the wave-2 session+window drift
|
|
1540
|
-
/// fallback was removed. Drift now fails loudly as `leader_not_attached` —
|
|
1541
|
-
/// see resolve_inject_target and `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
|
|
1542
|
-
fn leader_receiver_pane_is_usable(
|
|
1543
|
-
transport: &dyn Transport,
|
|
1544
|
-
state: &serde_json::Value,
|
|
1545
|
-
live_targets: &[PaneInfo],
|
|
1546
|
-
) -> bool {
|
|
1547
|
-
let Some(pane_id) = leader_receiver_pane_id(state) else {
|
|
1548
|
-
return false;
|
|
1549
|
-
};
|
|
1550
|
-
if live_targets
|
|
1551
|
-
.iter()
|
|
1552
|
-
.any(|target| target.pane_id.as_str() == pane_id)
|
|
1553
|
-
{
|
|
1554
|
-
return true;
|
|
1555
|
-
}
|
|
1556
|
-
!matches!(
|
|
1557
|
-
transport.liveness(&PaneId::new(pane_id)),
|
|
1558
|
-
Ok(PaneLiveness::Dead)
|
|
1559
|
-
)
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
1555
|
/// 0.5.x Phase 1d Batch 4: the `Owned` variant now carries a boxed
|
|
1563
1556
|
/// `Transport` trait object rather than a concrete `TmuxBackend`. All
|
|
1564
1557
|
/// existing leader-fallback sites still supply a `TmuxBackend` here
|
|
@@ -1631,7 +1624,7 @@ fn delivery_transport_for_recipient<'a>(
|
|
|
1631
1624
|
// switching transports on a match can cross the team/tmux boundary and
|
|
1632
1625
|
// inject A's leader traffic into team B's leader pane. If the pane is
|
|
1633
1626
|
// absent on the recorded endpoint, let the downstream
|
|
1634
|
-
//
|
|
1627
|
+
// the shared leader-channel resolver produce `rebind_required`
|
|
1635
1628
|
// instead of falling back to another server.
|
|
1636
1629
|
let endpoint_backend = crate::transport_factory::tmux_endpoint_transport(socket);
|
|
1637
1630
|
DeliveryTransport::Owned(Box::new(endpoint_backend))
|
|
@@ -1673,20 +1666,6 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
|
|
|
1673
1666
|
leader_receiver_field(state, "tmux_socket")
|
|
1674
1667
|
}
|
|
1675
1668
|
|
|
1676
|
-
/// 0.5.4 gate054 cross-team leak fix: read the bound `leader_receiver.status`
|
|
1677
|
-
/// through the same team-scope resolution as [`leader_receiver_tmux_socket`].
|
|
1678
|
-
/// Restart marks demoted receivers as `"rebind_required"`
|
|
1679
|
-
/// (lifecycle/restart/rebuild.rs:1311); delivery must refuse before injecting
|
|
1680
|
-
/// or cross-server pane-id probing (delivery.rs:1150-1201).
|
|
1681
|
-
fn leader_receiver_status(state: &serde_json::Value) -> Option<&str> {
|
|
1682
|
-
leader_receiver_field(state, "status")
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
|
|
1686
|
-
leader_receiver_tmux_socket(state)
|
|
1687
|
-
.is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
1669
|
fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
|
|
1691
1670
|
leader_receiver_field_in_state(state, field)
|
|
1692
1671
|
.or_else(|| {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use crate::codex_app_server::AppServerBinding;
|
|
6
|
+
use crate::transport::{PaneInfo, Transport};
|
|
7
|
+
|
|
8
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
9
|
+
pub enum LiveLeaderChannel {
|
|
10
|
+
DirectTmux(DirectTmuxLeaderChannel),
|
|
11
|
+
CodexAppServer(AppServerBinding),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
15
|
+
pub struct DirectTmuxLeaderChannel {
|
|
16
|
+
pub pane_id: String,
|
|
17
|
+
pub tmux_socket: Option<String>,
|
|
18
|
+
pub observed: PaneInfo,
|
|
19
|
+
pub metadata_drift: Vec<&'static str>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
23
|
+
pub enum LeaderChannelResolution {
|
|
24
|
+
Live(LiveLeaderChannel),
|
|
25
|
+
Unbound(LeaderChannelUnbound),
|
|
26
|
+
ProbeFailed(String),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
30
|
+
pub enum LeaderChannelUnbound {
|
|
31
|
+
ReceiverNotAttached,
|
|
32
|
+
TransportConflict,
|
|
33
|
+
MissingPaneId,
|
|
34
|
+
NonCanonicalTmuxSocket,
|
|
35
|
+
EndpointMismatch,
|
|
36
|
+
PaneNotLive,
|
|
37
|
+
PaneWorkspaceMismatch,
|
|
38
|
+
AppServerBindingInvalid,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Resolve the canonical receiver's live physical channel without mutating
|
|
42
|
+
/// ownership. For direct tmux, `(absolute socket, pane id)` plus an available
|
|
43
|
+
/// pane cwd inside the target workspace is the authority. Session/window/tty/
|
|
44
|
+
/// fingerprint are observations and can only report drift.
|
|
45
|
+
pub fn resolve_live_leader_channel(
|
|
46
|
+
workspace: &Path,
|
|
47
|
+
receiver: &Value,
|
|
48
|
+
transport: &dyn Transport,
|
|
49
|
+
) -> LeaderChannelResolution {
|
|
50
|
+
if receiver
|
|
51
|
+
.get("status")
|
|
52
|
+
.and_then(Value::as_str)
|
|
53
|
+
.is_some_and(|status| status != "attached")
|
|
54
|
+
{
|
|
55
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached);
|
|
56
|
+
}
|
|
57
|
+
if receiver_transport_conflicts(receiver) {
|
|
58
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::TransportConflict);
|
|
59
|
+
}
|
|
60
|
+
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
61
|
+
return match crate::codex_app_server::binding_from_receiver(receiver) {
|
|
62
|
+
Ok(binding) => {
|
|
63
|
+
LeaderChannelResolution::Live(LiveLeaderChannel::CodexAppServer(binding))
|
|
64
|
+
}
|
|
65
|
+
Err(_) => {
|
|
66
|
+
LeaderChannelResolution::Unbound(LeaderChannelUnbound::AppServerBindingInvalid)
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let Some(pane_id) = receiver
|
|
72
|
+
.get("pane_id")
|
|
73
|
+
.and_then(Value::as_str)
|
|
74
|
+
.filter(|pane| !pane.is_empty())
|
|
75
|
+
else {
|
|
76
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::MissingPaneId);
|
|
77
|
+
};
|
|
78
|
+
let tmux_socket = receiver
|
|
79
|
+
.get("tmux_socket")
|
|
80
|
+
.and_then(Value::as_str)
|
|
81
|
+
.filter(|socket| !socket.is_empty());
|
|
82
|
+
if tmux_socket.is_some_and(|socket| !std::path::Path::new(socket).is_absolute()) {
|
|
83
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::NonCanonicalTmuxSocket);
|
|
84
|
+
}
|
|
85
|
+
if let Some(expected) = tmux_socket {
|
|
86
|
+
if transport.tmux_endpoint().as_deref() != Some(expected) {
|
|
87
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let targets = match transport.list_targets() {
|
|
91
|
+
Ok(targets) => targets,
|
|
92
|
+
Err(error) => return LeaderChannelResolution::ProbeFailed(error.to_string()),
|
|
93
|
+
};
|
|
94
|
+
let Some(observed) = targets
|
|
95
|
+
.into_iter()
|
|
96
|
+
.find(|target| target.pane_id.as_str() == pane_id)
|
|
97
|
+
else {
|
|
98
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive);
|
|
99
|
+
};
|
|
100
|
+
if observed
|
|
101
|
+
.current_path
|
|
102
|
+
.as_deref()
|
|
103
|
+
.is_some_and(|path| !path_is_in_workspace(path, workspace))
|
|
104
|
+
&& !explicit_claim_authority_matches(workspace, receiver, &observed)
|
|
105
|
+
{
|
|
106
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
|
|
107
|
+
}
|
|
108
|
+
let metadata_drift = receiver_metadata_drift(receiver, &observed);
|
|
109
|
+
LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
|
|
110
|
+
pane_id: pane_id.to_string(),
|
|
111
|
+
tmux_socket: tmux_socket.map(str::to_string),
|
|
112
|
+
observed,
|
|
113
|
+
metadata_drift,
|
|
114
|
+
}))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn explicit_claim_authority_matches(
|
|
118
|
+
workspace: &Path,
|
|
119
|
+
receiver: &Value,
|
|
120
|
+
observed: &PaneInfo,
|
|
121
|
+
) -> bool {
|
|
122
|
+
if string_field(receiver, "scope_authority") != Some("explicit_claim") {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {
|
|
126
|
+
return false;
|
|
127
|
+
};
|
|
128
|
+
if canonical_path(Path::new(authorized_workspace)) != canonical_path(workspace) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
let Some(recorded_nonce) = string_field(receiver, "binding_nonce") else {
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
observed
|
|
135
|
+
.leader_env
|
|
136
|
+
.get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
|
|
137
|
+
.filter(|nonce| !nonce.is_empty())
|
|
138
|
+
.is_some_and(|live_nonce| live_nonce == recorded_nonce)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pub(crate) fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
|
|
142
|
+
let path = canonical_path(path);
|
|
143
|
+
let workspace = canonical_path(workspace);
|
|
144
|
+
path == workspace || path.starts_with(workspace)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fn canonical_path(path: &Path) -> std::path::PathBuf {
|
|
148
|
+
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fn receiver_transport_conflicts(receiver: &Value) -> bool {
|
|
152
|
+
let mode = receiver.get("mode").and_then(Value::as_str);
|
|
153
|
+
let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
|
|
154
|
+
matches!((mode, transport_kind), (Some(mode), Some(kind)) if !mode.is_empty() && !kind.is_empty() && mode != kind)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn receiver_metadata_drift(receiver: &Value, observed: &PaneInfo) -> Vec<&'static str> {
|
|
158
|
+
let mut drift = Vec::new();
|
|
159
|
+
if string_field(receiver, "session_name")
|
|
160
|
+
.is_some_and(|expected| expected != observed.session.as_str())
|
|
161
|
+
{
|
|
162
|
+
drift.push("session_name");
|
|
163
|
+
}
|
|
164
|
+
if string_field(receiver, "window_name").is_some_and(|expected| {
|
|
165
|
+
observed.window_name.as_ref().map(|value| value.as_str()) != Some(expected)
|
|
166
|
+
}) {
|
|
167
|
+
drift.push("window_name");
|
|
168
|
+
}
|
|
169
|
+
if string_field(receiver, "pane_tty")
|
|
170
|
+
.is_some_and(|expected| observed.tty.as_deref() != Some(expected))
|
|
171
|
+
{
|
|
172
|
+
drift.push("pane_tty");
|
|
173
|
+
}
|
|
174
|
+
let observed_fingerprint = format!(
|
|
175
|
+
"{}|{}|{}|{}",
|
|
176
|
+
observed.session.as_str(),
|
|
177
|
+
observed
|
|
178
|
+
.window_index
|
|
179
|
+
.map_or_else(String::new, |value| value.to_string()),
|
|
180
|
+
observed
|
|
181
|
+
.pane_index
|
|
182
|
+
.map_or_else(String::new, |value| value.to_string()),
|
|
183
|
+
observed.tty.as_deref().unwrap_or("")
|
|
184
|
+
);
|
|
185
|
+
if string_field(receiver, "fingerprint")
|
|
186
|
+
.is_some_and(|expected| expected != observed_fingerprint)
|
|
187
|
+
{
|
|
188
|
+
drift.push("fingerprint");
|
|
189
|
+
}
|
|
190
|
+
drift
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
194
|
+
value
|
|
195
|
+
.get(key)
|
|
196
|
+
.and_then(Value::as_str)
|
|
197
|
+
.filter(|value| !value.is_empty())
|
|
198
|
+
}
|