@team-agent/installer 0.5.50 → 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/adapters.rs +5 -3
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +32 -14
- package/crates/team-agent/src/cli/named_address.rs +46 -115
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +44 -1169
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/lease.rs +202 -16
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
- package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
- package/crates/team-agent/src/lifecycle/restart.rs +26 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
- 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/mcp_server/tools.rs +14 -5
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +288 -183
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +15 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +101 -49
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +284 -21
- 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/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +94 -27
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -841,8 +841,8 @@ fn provider_command(provider: &str) -> &str {
|
|
|
841
841
|
}
|
|
842
842
|
|
|
843
843
|
fn seed_fake_e2e_state(workspace: &Path) -> Result<(), CliError> {
|
|
844
|
-
crate::state::
|
|
845
|
-
|
|
844
|
+
crate::state::repository::StateRepository::new(workspace).save(
|
|
845
|
+
crate::state::repository::StateWriteIntent::FakeE2eSeed,
|
|
846
846
|
&json!({
|
|
847
847
|
"leader": {"id": "leader"},
|
|
848
848
|
"session_name": "team-agent-fake-e2e",
|
|
@@ -876,7 +876,8 @@ fn fake_shutdown(workspace: &Path) -> Result<Value, CliError> {
|
|
|
876
876
|
}
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
|
-
crate::state::
|
|
879
|
+
crate::state::repository::StateRepository::new(workspace)
|
|
880
|
+
.save(crate::state::repository::StateWriteIntent::FakeE2eSeed, &state)?;
|
|
880
881
|
Ok(json!({
|
|
881
882
|
"ok": true,
|
|
882
883
|
"session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
|
|
@@ -1353,6 +1354,7 @@ pub fn cmd_add_agent(args: &AddAgentArgs) -> Result<CmdResult, CliError> {
|
|
|
1353
1354
|
&args.role_file,
|
|
1354
1355
|
!args.no_display,
|
|
1355
1356
|
args.team.as_deref(),
|
|
1357
|
+
args.force,
|
|
1356
1358
|
)?,
|
|
1357
1359
|
args.json,
|
|
1358
1360
|
))
|
|
@@ -329,7 +329,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
329
329
|
Some("reset-agent") => "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]".to_string(),
|
|
330
330
|
Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]".to_string(),
|
|
331
331
|
Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
332
|
-
Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
|
|
332
|
+
Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
|
|
333
333
|
Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
|
|
334
334
|
Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
|
|
335
335
|
// 0.5.26 (§7.6): removed from help; dispatch was never wired.
|
|
@@ -1345,6 +1345,7 @@ fn add_agent_args(args: &[String], cwd: &Path) -> Result<AddAgentArgs, CliError>
|
|
|
1345
1345
|
role_file: parsed
|
|
1346
1346
|
.role_file
|
|
1347
1347
|
.ok_or_else(|| CliError::Usage("missing --role-file".to_string()))?,
|
|
1348
|
+
force: parsed.force,
|
|
1348
1349
|
no_display: parsed.no_display,
|
|
1349
1350
|
json: parsed.json,
|
|
1350
1351
|
})
|
|
@@ -884,7 +884,13 @@ pub mod lifecycle_port {
|
|
|
884
884
|
if session_killed && !verification_degraded {
|
|
885
885
|
mark_active_team_shutdown(&mut state, team_shutdown_status);
|
|
886
886
|
}
|
|
887
|
-
crate::state::
|
|
887
|
+
crate::state::repository::StateRepository::new(&run_workspace).save(
|
|
888
|
+
crate::state::repository::StateWriteIntent::ShutdownTeam {
|
|
889
|
+
team_key: team,
|
|
890
|
+
clean: session_killed && !verification_degraded,
|
|
891
|
+
},
|
|
892
|
+
&state,
|
|
893
|
+
)?;
|
|
888
894
|
promote_live_sibling_after_scoped_shutdown(&run_workspace, &state)?;
|
|
889
895
|
} else {
|
|
890
896
|
let _changed_keys = mark_matching_session_teams_stopped(
|
|
@@ -893,7 +899,13 @@ pub mod lifecycle_port {
|
|
|
893
899
|
session_killed && !verification_degraded,
|
|
894
900
|
team_shutdown_status,
|
|
895
901
|
);
|
|
896
|
-
crate::state::
|
|
902
|
+
crate::state::repository::StateRepository::new(&run_workspace).save(
|
|
903
|
+
crate::state::repository::StateWriteIntent::ShutdownTeam {
|
|
904
|
+
team_key: None,
|
|
905
|
+
clean: session_killed && !verification_degraded,
|
|
906
|
+
},
|
|
907
|
+
&state,
|
|
908
|
+
)?;
|
|
897
909
|
}
|
|
898
910
|
let coordinator_status = if coordinator_timeout {
|
|
899
911
|
"timeout"
|
|
@@ -2310,14 +2322,16 @@ pub mod lifecycle_port {
|
|
|
2310
2322
|
role_file: &str,
|
|
2311
2323
|
open_display: bool,
|
|
2312
2324
|
team: Option<&str>,
|
|
2325
|
+
force: bool,
|
|
2313
2326
|
) -> Result<Value, CliError> {
|
|
2314
2327
|
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
2315
|
-
match crate::lifecycle::
|
|
2328
|
+
match crate::lifecycle::add_agent_force(
|
|
2316
2329
|
workspace,
|
|
2317
2330
|
&agent_id,
|
|
2318
2331
|
Path::new(role_file),
|
|
2319
2332
|
open_display,
|
|
2320
2333
|
team,
|
|
2334
|
+
force,
|
|
2321
2335
|
) {
|
|
2322
2336
|
Ok(report) => Ok(json!({
|
|
2323
2337
|
"ok": true,
|
|
@@ -2372,7 +2386,7 @@ pub mod lifecycle_port {
|
|
|
2372
2386
|
));
|
|
2373
2387
|
}
|
|
2374
2388
|
}
|
|
2375
|
-
Err(error) if confirm => return Ok(error_value(error)),
|
|
2389
|
+
Err(error) if confirm && !force => return Ok(error_value(error)),
|
|
2376
2390
|
Err(_) => {}
|
|
2377
2391
|
}
|
|
2378
2392
|
if !confirm {
|
|
@@ -2548,7 +2562,13 @@ pub mod lifecycle_port {
|
|
|
2548
2562
|
.and_then(|agents| agents.keys().next().cloned())
|
|
2549
2563
|
.map(Value::String)
|
|
2550
2564
|
.unwrap_or(Value::Null);
|
|
2551
|
-
crate::state::
|
|
2565
|
+
crate::state::repository::StateRepository::new(workspace)
|
|
2566
|
+
.save(
|
|
2567
|
+
crate::state::repository::StateWriteIntent::IdleAck {
|
|
2568
|
+
team_key: Some(&team),
|
|
2569
|
+
},
|
|
2570
|
+
&state,
|
|
2571
|
+
)
|
|
2552
2572
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
2553
2573
|
crate::event_log::EventLog::new(workspace)
|
|
2554
2574
|
.write(
|
|
@@ -3553,7 +3573,13 @@ pub mod lifecycle_port {
|
|
|
3553
3573
|
return Ok(());
|
|
3554
3574
|
};
|
|
3555
3575
|
let promoted = crate::state::projection::project_top_level_view(&raw, next_key);
|
|
3556
|
-
crate::state::
|
|
3576
|
+
crate::state::repository::StateRepository::new(workspace).save(
|
|
3577
|
+
crate::state::repository::StateWriteIntent::PromoteLiveSiblingAfterShutdown {
|
|
3578
|
+
stopped_team_key: stopped_key,
|
|
3579
|
+
promoted_team_key: next_key,
|
|
3580
|
+
},
|
|
3581
|
+
&promoted,
|
|
3582
|
+
)?;
|
|
3557
3583
|
Ok(())
|
|
3558
3584
|
}
|
|
3559
3585
|
|
|
@@ -3966,14 +3992,6 @@ pub mod diagnose_port {
|
|
|
3966
3992
|
format!("{:012x}", now & 0xffffffffffff)
|
|
3967
3993
|
}
|
|
3968
3994
|
|
|
3969
|
-
fn read_runtime_state(workspace: &Path) -> Value {
|
|
3970
|
-
let path = workspace.join(".team").join("runtime").join("state.json");
|
|
3971
|
-
std::fs::read_to_string(path)
|
|
3972
|
-
.ok()
|
|
3973
|
-
.and_then(|s| serde_json::from_str(&s).ok())
|
|
3974
|
-
.unwrap_or_else(|| json!({}))
|
|
3975
|
-
}
|
|
3976
|
-
|
|
3977
3995
|
fn which_path(binary: &str) -> Option<String> {
|
|
3978
3996
|
let path = std::env::var_os("PATH")?;
|
|
3979
3997
|
for dir in std::env::split_paths(&path) {
|
|
@@ -505,57 +505,20 @@ enum ParsedTarget {
|
|
|
505
505
|
}
|
|
506
506
|
|
|
507
507
|
fn parse_named_address(raw_name: &str) -> Result<ParsedNamedAddress, NamedAddressError> {
|
|
508
|
-
let
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if workspace.trim().is_empty() || rest.trim().is_empty() {
|
|
514
|
-
return Err(name_invalid(
|
|
515
|
-
"workspace-qualified name must include workspace and target",
|
|
516
|
-
));
|
|
517
|
-
}
|
|
518
|
-
(Some(PathBuf::from(workspace)), rest.trim())
|
|
519
|
-
} else {
|
|
520
|
-
(None, raw)
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
if name.contains("//") {
|
|
524
|
-
return Err(name_invalid("name contains an empty path segment"));
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
let target = if name.contains('/') {
|
|
528
|
-
let parts = name.split('/').collect::<Vec<_>>();
|
|
529
|
-
if parts.len() != 2 || !valid_component(parts[0]) || !valid_component(parts[1]) {
|
|
530
|
-
return Err(name_invalid("expected <team>/<agent> or <team>/leader"));
|
|
531
|
-
}
|
|
532
|
-
ParsedTarget::TeamEntity {
|
|
533
|
-
team: parts[0].to_string(),
|
|
534
|
-
entity: parts[1].to_string(),
|
|
535
|
-
}
|
|
536
|
-
} else if name.contains(':') {
|
|
537
|
-
let parts = name.split(':').collect::<Vec<_>>();
|
|
538
|
-
if parts.len() != 2 || parts[0].trim().is_empty() || parts[1].trim().is_empty() {
|
|
539
|
-
return Err(name_invalid("expected <session>:<window>"));
|
|
508
|
+
let parsed = crate::messaging::parse_logical_address(raw_name).map_err(name_invalid)?;
|
|
509
|
+
let target = match parsed.target {
|
|
510
|
+
crate::messaging::LogicalAddressTarget::Worker(agent) => ParsedTarget::BareAgent(agent),
|
|
511
|
+
crate::messaging::LogicalAddressTarget::TeamEntity { team, entity } => {
|
|
512
|
+
ParsedTarget::TeamEntity { team, entity }
|
|
540
513
|
}
|
|
541
|
-
|
|
542
|
-
session
|
|
543
|
-
window: parts[1].to_string(),
|
|
514
|
+
crate::messaging::LogicalAddressTarget::SessionWindow { session, window } => {
|
|
515
|
+
ParsedTarget::SessionWindow { session, window }
|
|
544
516
|
}
|
|
545
|
-
} else {
|
|
546
|
-
if !valid_component(name) {
|
|
547
|
-
return Err(name_invalid("expected a non-empty agent id"));
|
|
548
|
-
}
|
|
549
|
-
ParsedTarget::BareAgent(name.to_string())
|
|
550
517
|
};
|
|
551
|
-
Ok(ParsedNamedAddress {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
!raw.trim().is_empty()
|
|
556
|
-
&& !raw.contains(char::is_whitespace)
|
|
557
|
-
&& !raw.contains('/')
|
|
558
|
-
&& !raw.contains(':')
|
|
518
|
+
Ok(ParsedNamedAddress {
|
|
519
|
+
workspace: parsed.workspace,
|
|
520
|
+
target,
|
|
521
|
+
})
|
|
559
522
|
}
|
|
560
523
|
|
|
561
524
|
fn resolve_workspace(
|
|
@@ -976,103 +939,71 @@ fn resolve_leader(
|
|
|
976
939
|
);
|
|
977
940
|
return Err(err);
|
|
978
941
|
}
|
|
979
|
-
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
980
|
-
return resolve_app_server_leader(
|
|
981
|
-
sender_workspace,
|
|
982
|
-
target_workspace,
|
|
983
|
-
team,
|
|
984
|
-
parsed,
|
|
985
|
-
receiver,
|
|
986
|
-
);
|
|
987
|
-
}
|
|
988
|
-
let pane_id = string_field(receiver, "pane_id")
|
|
989
|
-
.filter(|pane| !pane.is_empty())
|
|
990
|
-
.ok_or_else(|| {
|
|
991
|
-
let mut err = leader_not_attached_third_party(
|
|
992
|
-
target_workspace,
|
|
993
|
-
team,
|
|
994
|
-
None,
|
|
995
|
-
None,
|
|
996
|
-
None,
|
|
997
|
-
transport.tmux_endpoint(),
|
|
998
|
-
);
|
|
999
|
-
err.candidates =
|
|
1000
|
-
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
1001
|
-
err
|
|
1002
|
-
})?;
|
|
1003
942
|
let session = string_field(receiver, "session_name");
|
|
1004
943
|
let window = string_field(receiver, "window_name");
|
|
1005
944
|
let socket = string_field(receiver, "tmux_socket")
|
|
1006
945
|
.map(str::to_string)
|
|
1007
946
|
.or_else(|| transport.tmux_endpoint());
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
.window_name
|
|
1017
|
-
.as_ref()
|
|
1018
|
-
.is_some_and(|name| name.as_str() == expected)
|
|
1019
|
-
})
|
|
1020
|
-
})
|
|
1021
|
-
.collect::<Vec<_>>();
|
|
1022
|
-
let stale_pane_seen = targets
|
|
1023
|
-
.iter()
|
|
1024
|
-
.any(|target| target.pane_id.as_str() == pane_id);
|
|
1025
|
-
match matches.len() {
|
|
1026
|
-
1 => Ok(ResolvedNamedAddress {
|
|
947
|
+
match crate::messaging::resolve_live_leader_channel(
|
|
948
|
+
target_workspace,
|
|
949
|
+
receiver,
|
|
950
|
+
transport,
|
|
951
|
+
) {
|
|
952
|
+
crate::messaging::LeaderChannelResolution::Live(
|
|
953
|
+
crate::messaging::LiveLeaderChannel::DirectTmux(channel),
|
|
954
|
+
) => Ok(ResolvedNamedAddress {
|
|
1027
955
|
raw_name: parsed.display_name(),
|
|
1028
956
|
target_kind: NamedTargetKind::Leader,
|
|
1029
957
|
sender_workspace: sender_workspace.to_path_buf(),
|
|
1030
958
|
target_workspace: target_workspace.to_path_buf(),
|
|
1031
959
|
team_key: Some(team.to_string()),
|
|
1032
960
|
agent_id: None,
|
|
1033
|
-
pane_id: pane_id.
|
|
961
|
+
pane_id: channel.pane_id.clone(),
|
|
1034
962
|
session_name: session.map(str::to_string),
|
|
1035
963
|
window_name: window.map(str::to_string),
|
|
1036
964
|
tmux_endpoint: socket,
|
|
1037
965
|
transport_kind: Some("direct_tmux".to_string()),
|
|
1038
966
|
app_server: None,
|
|
1039
|
-
state_pane_id: Some(pane_id
|
|
967
|
+
state_pane_id: Some(channel.pane_id),
|
|
1040
968
|
state_pane_stale: false,
|
|
1041
969
|
agent_status: None,
|
|
1042
|
-
warning:
|
|
970
|
+
warning: (!channel.metadata_drift.is_empty()).then(|| {
|
|
971
|
+
format!(
|
|
972
|
+
"leader_channel_metadata_drift:{}",
|
|
973
|
+
channel.metadata_drift.join(",")
|
|
974
|
+
)
|
|
975
|
+
}),
|
|
1043
976
|
}),
|
|
1044
|
-
|
|
977
|
+
crate::messaging::LeaderChannelResolution::Live(
|
|
978
|
+
crate::messaging::LiveLeaderChannel::CodexAppServer(_),
|
|
979
|
+
) => resolve_app_server_leader(sender_workspace, target_workspace, team, parsed, receiver),
|
|
980
|
+
crate::messaging::LeaderChannelResolution::Unbound(reason) => {
|
|
981
|
+
let pane_id = string_field(receiver, "pane_id");
|
|
1045
982
|
let mut err = leader_not_attached_third_party(
|
|
1046
983
|
target_workspace,
|
|
1047
984
|
team,
|
|
1048
|
-
|
|
985
|
+
pane_id,
|
|
1049
986
|
session,
|
|
1050
987
|
window,
|
|
1051
988
|
socket,
|
|
1052
989
|
);
|
|
1053
|
-
|
|
1054
|
-
err.log = format!("{} stale_tuple_mismatch=true", err.log);
|
|
1055
|
-
}
|
|
990
|
+
err.log = format!("{} channel_unbound={reason:?}", err.log);
|
|
1056
991
|
err.candidates =
|
|
1057
992
|
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
1058
993
|
Err(err)
|
|
1059
994
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
})
|
|
1073
|
-
})
|
|
1074
|
-
.collect(),
|
|
1075
|
-
)),
|
|
995
|
+
crate::messaging::LeaderChannelResolution::ProbeFailed(error) => {
|
|
996
|
+
let mut err = leader_not_attached_third_party(
|
|
997
|
+
target_workspace,
|
|
998
|
+
team,
|
|
999
|
+
string_field(receiver, "pane_id"),
|
|
1000
|
+
session,
|
|
1001
|
+
window,
|
|
1002
|
+
socket,
|
|
1003
|
+
);
|
|
1004
|
+
err.log = format!("{} channel_probe_failed={error}", err.log);
|
|
1005
|
+
Err(err)
|
|
1006
|
+
}
|
|
1076
1007
|
}
|
|
1077
1008
|
}
|
|
1078
1009
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn dirty_topology_refusal_value(
|
|
4
|
+
selected: &crate::state::selector::SelectedTeam,
|
|
5
|
+
requested_team: Option<&str>,
|
|
6
|
+
) -> Option<Value> {
|
|
7
|
+
let issue_ids = crate::topology::restart_dirty_topology_issue_ids(&selected.state);
|
|
8
|
+
if issue_ids.is_empty() {
|
|
9
|
+
return None;
|
|
10
|
+
}
|
|
11
|
+
let session_name = selected
|
|
12
|
+
.state
|
|
13
|
+
.get("session_name")
|
|
14
|
+
.and_then(Value::as_str)
|
|
15
|
+
.unwrap_or_default()
|
|
16
|
+
.to_string();
|
|
17
|
+
let reason = issue_ids
|
|
18
|
+
.first()
|
|
19
|
+
.cloned()
|
|
20
|
+
.unwrap_or_else(|| "dirty_topology".to_string());
|
|
21
|
+
let repair_team = requested_team
|
|
22
|
+
.filter(|team| !team.is_empty())
|
|
23
|
+
.unwrap_or(selected.team_key.as_str());
|
|
24
|
+
Some(json!({
|
|
25
|
+
"ok": false,
|
|
26
|
+
"status": "refused_dirty_topology",
|
|
27
|
+
"reason": reason,
|
|
28
|
+
"session_name": session_name,
|
|
29
|
+
"error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
|
|
30
|
+
"issues": issue_ids
|
|
31
|
+
.iter()
|
|
32
|
+
.map(|id| json!({"id": id}))
|
|
33
|
+
.collect::<Vec<_>>(),
|
|
34
|
+
"next_actions": [
|
|
35
|
+
"team-agent diagnose --json",
|
|
36
|
+
format!("team-agent claim-leader --team {repair_team} --confirm --json"),
|
|
37
|
+
format!("team-agent takeover --team {repair_team} --confirm --json")
|
|
38
|
+
],
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub(super) fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
|
|
43
|
+
let Some(agents) = state.get("agents").and_then(Value::as_object) else {
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
match target {
|
|
47
|
+
MessageTarget::Single(target) => agents.contains_key(target),
|
|
48
|
+
MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
|
|
49
|
+
MessageTarget::Fanout(recipients) => recipients
|
|
50
|
+
.iter()
|
|
51
|
+
.any(|recipient| agents.contains_key(recipient)),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(Debug, Clone)]
|
|
56
|
+
pub(super) struct LoudEnsureResult {
|
|
57
|
+
previous_status: String,
|
|
58
|
+
start: crate::coordinator::StartReport,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub(super) fn loud_ensure_coordinator(
|
|
62
|
+
selected: &crate::state::selector::SelectedTeam,
|
|
63
|
+
) -> Result<Option<LoudEnsureResult>, CliError> {
|
|
64
|
+
if in_process_unit_test() {
|
|
65
|
+
return Ok(None);
|
|
66
|
+
}
|
|
67
|
+
let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
|
|
68
|
+
let previous = crate::coordinator::coordinator_health(&workspace);
|
|
69
|
+
if previous.ok {
|
|
70
|
+
return Ok(None);
|
|
71
|
+
}
|
|
72
|
+
if previous.service_available
|
|
73
|
+
&& matches!(
|
|
74
|
+
previous.binary_identity_relation,
|
|
75
|
+
crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
|
|
76
|
+
)
|
|
77
|
+
{
|
|
78
|
+
return Ok(None);
|
|
79
|
+
}
|
|
80
|
+
let previous_status = coordinator_health_status_wire(previous.status).to_string();
|
|
81
|
+
let start = crate::coordinator::start_coordinator_with_team(
|
|
82
|
+
&workspace,
|
|
83
|
+
Some(selected.team_key.as_str()),
|
|
84
|
+
)
|
|
85
|
+
.map_err(|error| CliError::Runtime(error.to_string()))?;
|
|
86
|
+
if !start.ok {
|
|
87
|
+
return Ok(Some(LoudEnsureResult {
|
|
88
|
+
previous_status,
|
|
89
|
+
start,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
if matches!(
|
|
93
|
+
start.status,
|
|
94
|
+
crate::coordinator::StartOutcome::Started
|
|
95
|
+
| crate::coordinator::StartOutcome::StartedAfterRotation
|
|
96
|
+
) {
|
|
97
|
+
crate::event_log::EventLog::new(&selected.run_workspace)
|
|
98
|
+
.write(
|
|
99
|
+
"coordinator.ensure_restarted",
|
|
100
|
+
json!({
|
|
101
|
+
"coordinator_previous_status": previous_status,
|
|
102
|
+
"status": start.status,
|
|
103
|
+
"pid": start.pid.map(|pid| pid.get()),
|
|
104
|
+
"previous_pid": start.previous_pid.map(|pid| pid.get()),
|
|
105
|
+
"binary_path": start.binary_path,
|
|
106
|
+
"binary_version": start.binary_version,
|
|
107
|
+
"rotation_reason": start.rotation_reason,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
.map_err(|error| CliError::Runtime(error.to_string()))?;
|
|
111
|
+
return Ok(Some(LoudEnsureResult {
|
|
112
|
+
previous_status,
|
|
113
|
+
start,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
Ok(None)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[cfg(test)]
|
|
120
|
+
pub(super) fn in_process_unit_test() -> bool {
|
|
121
|
+
true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#[cfg(not(test))]
|
|
125
|
+
pub(super) fn in_process_unit_test() -> bool {
|
|
126
|
+
false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub(super) fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
|
|
130
|
+
let Some(ensure) = ensure else {
|
|
131
|
+
return;
|
|
132
|
+
};
|
|
133
|
+
if !ensure.start.ok {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if let Some(obj) = value.as_object_mut() {
|
|
137
|
+
obj.insert("coordinator_auto_restarted".to_string(), json!(true));
|
|
138
|
+
obj.insert(
|
|
139
|
+
"coordinator_previous_status".to_string(),
|
|
140
|
+
json!(ensure.previous_status),
|
|
141
|
+
);
|
|
142
|
+
obj.insert(
|
|
143
|
+
"coordinator".to_string(),
|
|
144
|
+
coordinator_start_json(&ensure.start),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
pub(super) fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
|
|
150
|
+
let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
|
|
151
|
+
crate::lifecycle::coordinator_start_summary_value(&summary)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
pub(super) fn coordinator_health_status_wire(
|
|
155
|
+
status: crate::coordinator::CoordinatorHealthStatus,
|
|
156
|
+
) -> &'static str {
|
|
157
|
+
match status {
|
|
158
|
+
crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
|
|
159
|
+
crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
|
|
160
|
+
crate::coordinator::CoordinatorHealthStatus::Running => "running",
|
|
161
|
+
crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn maybe_enqueue_offline_leader_mailbox(
|
|
4
|
+
sender_workspace: &Path,
|
|
5
|
+
to_name: &str,
|
|
6
|
+
content: &str,
|
|
7
|
+
sender: &str,
|
|
8
|
+
task_id: Option<&str>,
|
|
9
|
+
error: &crate::cli::named_address::NamedAddressError,
|
|
10
|
+
) -> Result<Option<Value>, CliError> {
|
|
11
|
+
if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
|
|
12
|
+
return Ok(None);
|
|
13
|
+
}
|
|
14
|
+
let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
|
|
15
|
+
sender_workspace,
|
|
16
|
+
to_name,
|
|
17
|
+
) {
|
|
18
|
+
Ok(Some(v)) => v,
|
|
19
|
+
Ok(None) => return Ok(None),
|
|
20
|
+
Err(_) => return Ok(None),
|
|
21
|
+
};
|
|
22
|
+
let (target_workspace, team_key) = parsed;
|
|
23
|
+
// Owner-scope refusal: sender workspace == target workspace. Keep
|
|
24
|
+
// the actionable attach hint (owner sees status/diagnose copy that
|
|
25
|
+
// points at `attach-leader`).
|
|
26
|
+
let sender_canonical =
|
|
27
|
+
std::fs::canonicalize(sender_workspace).unwrap_or_else(|_| sender_workspace.to_path_buf());
|
|
28
|
+
let target_canonical =
|
|
29
|
+
std::fs::canonicalize(&target_workspace).unwrap_or_else(|_| target_workspace.clone());
|
|
30
|
+
if sender_canonical == target_canonical {
|
|
31
|
+
return Ok(None);
|
|
32
|
+
}
|
|
33
|
+
// Verify the target team is actually alive on this host — mailbox
|
|
34
|
+
// is only for `team live + leader unattached`. Fail-closed otherwise
|
|
35
|
+
// so we never leave a message in a permanently-dead workspace's DB.
|
|
36
|
+
let state = match crate::state::persist::load_runtime_state(&target_workspace) {
|
|
37
|
+
Ok(s) => s,
|
|
38
|
+
Err(_) => return Ok(None),
|
|
39
|
+
};
|
|
40
|
+
let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
|
|
41
|
+
if !team_alive {
|
|
42
|
+
return Ok(None);
|
|
43
|
+
}
|
|
44
|
+
let event_log = crate::event_log::EventLog::new(&target_workspace);
|
|
45
|
+
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
46
|
+
let outcome = messaging::enqueue_leader_mailbox_until_attach(
|
|
47
|
+
&target_workspace,
|
|
48
|
+
&team_key,
|
|
49
|
+
content,
|
|
50
|
+
task.as_ref(),
|
|
51
|
+
sender,
|
|
52
|
+
&event_log,
|
|
53
|
+
)
|
|
54
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
55
|
+
let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
|
|
56
|
+
Ok(Some(json!({
|
|
57
|
+
"ok": true,
|
|
58
|
+
"status": "queued_until_leader_attach",
|
|
59
|
+
"message_status": "queued_until_leader_attach",
|
|
60
|
+
"channel": "leader_mailbox",
|
|
61
|
+
"delivered": false,
|
|
62
|
+
"to_name": to_name,
|
|
63
|
+
"target_workspace": target_workspace.display().to_string(),
|
|
64
|
+
"team_key": team_key,
|
|
65
|
+
"recipient": "leader",
|
|
66
|
+
"leader_attached": false,
|
|
67
|
+
"message_id": message_id,
|
|
68
|
+
})))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
|
|
72
|
+
/// - target workspace has state and the team key is present + not archived/down;
|
|
73
|
+
/// - AND at least one live tmux fact — a persisted `session_name` OR any
|
|
74
|
+
/// agent with a recorded pane on the recorded socket.
|
|
75
|
+
///
|
|
76
|
+
/// We deliberately do NOT poll coordinator health here — enqueuing is
|
|
77
|
+
/// safe even when the coordinator is transiently down; attach-leader
|
|
78
|
+
/// itself replays via `requeue_blocked_leader_messages` regardless.
|
|
79
|
+
pub(super) fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
|
|
80
|
+
let team = state
|
|
81
|
+
.get("teams")
|
|
82
|
+
.and_then(|v| v.as_object())
|
|
83
|
+
.and_then(|teams| teams.get(team_key));
|
|
84
|
+
let Some(team) = team else {
|
|
85
|
+
return false;
|
|
86
|
+
};
|
|
87
|
+
let status = team
|
|
88
|
+
.get("status")
|
|
89
|
+
.and_then(|v| v.as_str())
|
|
90
|
+
.unwrap_or("alive");
|
|
91
|
+
if matches!(status, "archived" | "down" | "stopped") {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
// A recorded session_name is enough — target's coordinator/attach
|
|
95
|
+
// path will re-verify tmux presence when the replay fires.
|
|
96
|
+
team.get("session_name")
|
|
97
|
+
.and_then(|v| v.as_str())
|
|
98
|
+
.is_some_and(|s| !s.is_empty())
|
|
99
|
+
}
|