@team-agent/installer 0.5.51 → 0.5.52
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/named_address.rs +35 -67
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +202 -16
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +7 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +19 -3
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +100 -1
- 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 +331 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +12 -0
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/messaging/delivery.rs +126 -147
- package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
- 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/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/package.json +4 -4
|
@@ -705,6 +705,7 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
|
|
|
705
705
|
#[test]
|
|
706
706
|
fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
|
|
707
707
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
708
|
+
seed_canonical_team_identity(&team, "teamdir");
|
|
708
709
|
// External role file OUTSIDE team_dir/agents, with recognizable body content.
|
|
709
710
|
let role = team.parent().unwrap().join("w2-external-role.md");
|
|
710
711
|
let role_body = "---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nUNIQUE-E5-BUG1-MARKER body for w2.\n";
|
|
@@ -1154,6 +1155,12 @@ pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
|
|
|
1154
1155
|
.unwrap();
|
|
1155
1156
|
}
|
|
1156
1157
|
|
|
1158
|
+
fn seed_canonical_team_identity(workspace: &std::path::Path, team_key: &str) {
|
|
1159
|
+
let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
1160
|
+
state["active_team_key"] = json!(team_key);
|
|
1161
|
+
crate::state::persist::save_runtime_state(workspace, &state).unwrap();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1157
1164
|
// RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
|
|
1158
1165
|
// non-empty (one per compiled agent), and the transport records >=1 spawn carrying that agent's
|
|
1159
1166
|
// provider build_command. Today the dry_run=true bug -> launch.started empty + ZERO spawns recorded ->
|
|
@@ -2263,6 +2270,7 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
|
|
|
2263
2270
|
let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
|
|
2264
2271
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
2265
2272
|
seed_healthy_coordinator(&team);
|
|
2273
|
+
seed_canonical_team_identity(&team, "addteam");
|
|
2266
2274
|
let transport = OfflineTransport::new();
|
|
2267
2275
|
|
|
2268
2276
|
let _result = add_agent_with_transport(
|
|
@@ -3331,6 +3339,7 @@ fn add_agent_reachability_gate_returns_error_when_spawn_pane_not_addressable() {
|
|
|
3331
3339
|
let role_file = team.join("worker2-role.md");
|
|
3332
3340
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
3333
3341
|
seed_healthy_coordinator(&team);
|
|
3342
|
+
seed_canonical_team_identity(&team, "reachgate");
|
|
3334
3343
|
|
|
3335
3344
|
// Models "spawn to wrong socket" / "spawn succeeded but pane orphan": the
|
|
3336
3345
|
// OfflineTransport records the spawn but reports the new pane is NOT
|
|
@@ -3390,6 +3399,7 @@ fn add_agent_reachability_gate_passes_when_spawn_pane_addressable() {
|
|
|
3390
3399
|
let role_file = team.join("worker2-role.md");
|
|
3391
3400
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
3392
3401
|
seed_healthy_coordinator(&team);
|
|
3402
|
+
seed_canonical_team_identity(&team, "gatepass");
|
|
3393
3403
|
|
|
3394
3404
|
// Default OfflineTransport: spawned_panes_addressable=true, liveness=Unknown
|
|
3395
3405
|
// (treated as not-Dead by the gate's fallthrough).
|
|
@@ -4149,6 +4159,7 @@ fn e42_re_add_and_remove_agree_on_existence_after_add() {
|
|
|
4149
4159
|
let role_file = team.join("newagent.md");
|
|
4150
4160
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
4151
4161
|
seed_healthy_coordinator(&team);
|
|
4162
|
+
seed_canonical_team_identity(&team, "e42sym");
|
|
4152
4163
|
let transport = OfflineTransport::new();
|
|
4153
4164
|
// (1) first add
|
|
4154
4165
|
let r1 = crate::lifecycle::add_agent_with_transport(
|
|
@@ -4205,6 +4216,7 @@ fn e42_add_writes_to_canonical_runtime_spec_and_state_consistently() {
|
|
|
4205
4216
|
let role_file = team.join("newagent.md");
|
|
4206
4217
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
4207
4218
|
seed_healthy_coordinator(&team);
|
|
4219
|
+
seed_canonical_team_identity(&team, "e42proj");
|
|
4208
4220
|
let transport = OfflineTransport::new();
|
|
4209
4221
|
let _ = crate::lifecycle::add_agent_with_transport(
|
|
4210
4222
|
&team,
|
|
@@ -311,8 +311,25 @@
|
|
|
311
311
|
// ════════════════════════════════════════════════════════════════════════
|
|
312
312
|
#[test]
|
|
313
313
|
fn update_state_returns_ok_and_state_file_path() {
|
|
314
|
+
let ws = seed_state_ws(
|
|
315
|
+
"update-state",
|
|
316
|
+
&json!({
|
|
317
|
+
"active_team_key": "teamA",
|
|
318
|
+
"team_key": "teamA",
|
|
319
|
+
"session_name": "teamA",
|
|
320
|
+
"status": "alive",
|
|
321
|
+
"teams": {
|
|
322
|
+
"teamA": {
|
|
323
|
+
"team_key": "teamA",
|
|
324
|
+
"session_name": "teamA",
|
|
325
|
+
"status": "alive",
|
|
326
|
+
"agents": {}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
314
331
|
let tools = TeamOrchestratorTools::with_identity(
|
|
315
|
-
&
|
|
332
|
+
&ws,
|
|
316
333
|
Some(AgentId::new("leader")),
|
|
317
334
|
Some(TeamKey::new("teamA")),
|
|
318
335
|
);
|
|
@@ -22,12 +22,19 @@ fn keys(v: &Value) -> Vec<String> {
|
|
|
22
22
|
/// `/tmp/ws`, or they flake under parallel cargo (sqlite "database is locked" / NotFound).
|
|
23
23
|
/// Pure-function / dispatch-shape tests that never touch fs/db keep a dummy fixed path.
|
|
24
24
|
fn unique_ws(tag: &str) -> std::path::PathBuf {
|
|
25
|
+
use std::io::ErrorKind;
|
|
25
26
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
26
27
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
loop {
|
|
29
|
+
let n = N.fetch_add(1, Ordering::Relaxed);
|
|
30
|
+
let p =
|
|
31
|
+
std::env::temp_dir().join(format!("ta-rs-mcp-{tag}-{}-{n}", std::process::id()));
|
|
32
|
+
match std::fs::create_dir(&p) {
|
|
33
|
+
Ok(()) => return p,
|
|
34
|
+
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
|
|
35
|
+
Err(error) => panic!("create unique workspace {}: {error}", p.display()),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
include!("tests/normalize.rs");
|
|
@@ -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,171 @@
|
|
|
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
|
+
{
|
|
105
|
+
return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
|
|
106
|
+
}
|
|
107
|
+
let metadata_drift = receiver_metadata_drift(receiver, &observed);
|
|
108
|
+
LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
|
|
109
|
+
pane_id: pane_id.to_string(),
|
|
110
|
+
tmux_socket: tmux_socket.map(str::to_string),
|
|
111
|
+
observed,
|
|
112
|
+
metadata_drift,
|
|
113
|
+
}))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
|
|
117
|
+
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
|
118
|
+
let workspace = workspace
|
|
119
|
+
.canonicalize()
|
|
120
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
121
|
+
path == workspace || path.starts_with(workspace)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn receiver_transport_conflicts(receiver: &Value) -> bool {
|
|
125
|
+
let mode = receiver.get("mode").and_then(Value::as_str);
|
|
126
|
+
let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
|
|
127
|
+
matches!((mode, transport_kind), (Some(mode), Some(kind)) if !mode.is_empty() && !kind.is_empty() && mode != kind)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn receiver_metadata_drift(receiver: &Value, observed: &PaneInfo) -> Vec<&'static str> {
|
|
131
|
+
let mut drift = Vec::new();
|
|
132
|
+
if string_field(receiver, "session_name")
|
|
133
|
+
.is_some_and(|expected| expected != observed.session.as_str())
|
|
134
|
+
{
|
|
135
|
+
drift.push("session_name");
|
|
136
|
+
}
|
|
137
|
+
if string_field(receiver, "window_name").is_some_and(|expected| {
|
|
138
|
+
observed.window_name.as_ref().map(|value| value.as_str()) != Some(expected)
|
|
139
|
+
}) {
|
|
140
|
+
drift.push("window_name");
|
|
141
|
+
}
|
|
142
|
+
if string_field(receiver, "pane_tty")
|
|
143
|
+
.is_some_and(|expected| observed.tty.as_deref() != Some(expected))
|
|
144
|
+
{
|
|
145
|
+
drift.push("pane_tty");
|
|
146
|
+
}
|
|
147
|
+
let observed_fingerprint = format!(
|
|
148
|
+
"{}|{}|{}|{}",
|
|
149
|
+
observed.session.as_str(),
|
|
150
|
+
observed
|
|
151
|
+
.window_index
|
|
152
|
+
.map_or_else(String::new, |value| value.to_string()),
|
|
153
|
+
observed
|
|
154
|
+
.pane_index
|
|
155
|
+
.map_or_else(String::new, |value| value.to_string()),
|
|
156
|
+
observed.tty.as_deref().unwrap_or("")
|
|
157
|
+
);
|
|
158
|
+
if string_field(receiver, "fingerprint")
|
|
159
|
+
.is_some_and(|expected| expected != observed_fingerprint)
|
|
160
|
+
{
|
|
161
|
+
drift.push("fingerprint");
|
|
162
|
+
}
|
|
163
|
+
drift
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
167
|
+
value
|
|
168
|
+
.get(key)
|
|
169
|
+
.and_then(Value::as_str)
|
|
170
|
+
.filter(|value| !value.is_empty())
|
|
171
|
+
}
|
|
@@ -64,6 +64,7 @@ pub mod activity;
|
|
|
64
64
|
pub mod address;
|
|
65
65
|
pub mod delivery;
|
|
66
66
|
pub mod helpers;
|
|
67
|
+
pub mod leader_channel;
|
|
67
68
|
pub mod leader_receiver;
|
|
68
69
|
pub mod peers;
|
|
69
70
|
pub mod persist;
|
|
@@ -86,6 +87,10 @@ pub use delivery::{
|
|
|
86
87
|
tmux_pane_width,
|
|
87
88
|
};
|
|
88
89
|
pub use helpers::fail_leader_delivery;
|
|
90
|
+
pub use leader_channel::{
|
|
91
|
+
resolve_live_leader_channel, DirectTmuxLeaderChannel, LeaderChannelResolution,
|
|
92
|
+
LeaderChannelUnbound, LiveLeaderChannel,
|
|
93
|
+
};
|
|
89
94
|
pub use leader_receiver::{
|
|
90
95
|
deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
|
|
91
96
|
mirror_peer_message_to_leader, send_to_leader_receiver,
|