@team-agent/installer 0.5.16 → 0.5.18
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/mod.rs +51 -70
- package/crates/team-agent/src/cli/status_port.rs +5 -0
- package/crates/team-agent/src/cli/tests/lane_c.rs +17 -7
- package/crates/team-agent/src/coordinator/backoff.rs +15 -14
- package/crates/team-agent/src/coordinator/health.rs +142 -12
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +3 -0
- package/crates/team-agent/src/coordinator/tick.rs +70 -68
- package/crates/team-agent/src/coordinator/types.rs +47 -0
- package/crates/team-agent/src/diagnose/orphans.rs +35 -13
- package/crates/team-agent/src/leader/registry.rs +104 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -2
- package/crates/team-agent/src/lifecycle/restart/common.rs +6 -3
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +39 -20
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +218 -39
- package/crates/team-agent/src/lifecycle/types.rs +48 -0
- package/crates/team-agent/src/messaging/results.rs +7 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -821,7 +821,11 @@ pub mod lifecycle_port {
|
|
|
821
821
|
let mut coordinator_timeout = false;
|
|
822
822
|
let mut coordinator_post_stop = CoordinatorStopObservation::NotNeeded;
|
|
823
823
|
let mut coordinator_pid_for_report = None;
|
|
824
|
-
let
|
|
824
|
+
let coordinator_stop_reason =
|
|
825
|
+
coordinator_stop_reason_for_shutdown(&run_workspace, team, &state)?;
|
|
826
|
+
let should_stop_coordinator =
|
|
827
|
+
matches!(coordinator_stop_reason, "bare_shutdown" | "scoped_last_live_team");
|
|
828
|
+
let stopped = if should_stop_coordinator {
|
|
825
829
|
let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
|
|
826
830
|
let coordinator_pid_before_stop = crate::coordinator::coordinator_health(&wp).pid;
|
|
827
831
|
coordinator_pid_for_report = coordinator_pid_before_stop.map(|pid| pid.get());
|
|
@@ -929,6 +933,7 @@ pub mod lifecycle_port {
|
|
|
929
933
|
"killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
930
934
|
"spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
931
935
|
"coordinator_status": coordinator_status,
|
|
936
|
+
"coordinator_stop_reason": coordinator_stop_reason,
|
|
932
937
|
"status": status,
|
|
933
938
|
"phase": phase,
|
|
934
939
|
"verification_degraded": verification_degraded,
|
|
@@ -971,9 +976,11 @@ pub mod lifecycle_port {
|
|
|
971
976
|
"owned_files": owned_file_residuals,
|
|
972
977
|
},
|
|
973
978
|
"error": kill_error,
|
|
979
|
+
"coordinator_stop_reason": coordinator_stop_reason,
|
|
974
980
|
"coordinator": {
|
|
975
981
|
"status": coordinator_status,
|
|
976
982
|
"pid": coordinator_pid,
|
|
983
|
+
"stop_reason": coordinator_stop_reason,
|
|
977
984
|
},
|
|
978
985
|
});
|
|
979
986
|
#[cfg(windows)]
|
|
@@ -2921,6 +2928,7 @@ pub mod lifecycle_port {
|
|
|
2921
2928
|
session_name,
|
|
2922
2929
|
agents,
|
|
2923
2930
|
coordinator_started,
|
|
2931
|
+
coordinator,
|
|
2924
2932
|
next_actions,
|
|
2925
2933
|
attach_commands,
|
|
2926
2934
|
} => json!({
|
|
@@ -2929,6 +2937,7 @@ pub mod lifecycle_port {
|
|
|
2929
2937
|
"session_name": session_name.as_str(),
|
|
2930
2938
|
"agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
|
|
2931
2939
|
"coordinator_started": coordinator_started,
|
|
2940
|
+
"coordinator": crate::lifecycle::coordinator_start_summary_value(&coordinator),
|
|
2932
2941
|
"next_actions": next_actions,
|
|
2933
2942
|
"attach_commands": attach_commands,
|
|
2934
2943
|
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
@@ -2938,6 +2947,7 @@ pub mod lifecycle_port {
|
|
|
2938
2947
|
agents,
|
|
2939
2948
|
failed_agents,
|
|
2940
2949
|
coordinator_started,
|
|
2950
|
+
coordinator,
|
|
2941
2951
|
next_actions,
|
|
2942
2952
|
attach_commands,
|
|
2943
2953
|
} => json!({
|
|
@@ -2964,6 +2974,7 @@ pub mod lifecycle_port {
|
|
|
2964
2974
|
),
|
|
2965
2975
|
})).collect::<Vec<_>>(),
|
|
2966
2976
|
"coordinator_started": coordinator_started,
|
|
2977
|
+
"coordinator": crate::lifecycle::coordinator_start_summary_value(&coordinator),
|
|
2967
2978
|
"next_actions": next_actions,
|
|
2968
2979
|
"attach_commands": attach_commands,
|
|
2969
2980
|
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
@@ -3370,6 +3381,35 @@ pub mod lifecycle_port {
|
|
|
3370
3381
|
}
|
|
3371
3382
|
}
|
|
3372
3383
|
|
|
3384
|
+
fn coordinator_stop_reason_for_shutdown(
|
|
3385
|
+
workspace: &Path,
|
|
3386
|
+
team: Option<&str>,
|
|
3387
|
+
selected_state: &Value,
|
|
3388
|
+
) -> Result<&'static str, CliError> {
|
|
3389
|
+
let Some(requested_team) = team.filter(|team| !team.is_empty()) else {
|
|
3390
|
+
return Ok("bare_shutdown");
|
|
3391
|
+
};
|
|
3392
|
+
let stopped_key = selected_state
|
|
3393
|
+
.get("active_team_key")
|
|
3394
|
+
.and_then(Value::as_str)
|
|
3395
|
+
.filter(|key| !key.is_empty())
|
|
3396
|
+
.unwrap_or(requested_team);
|
|
3397
|
+
let raw = crate::state::persist::load_runtime_state(workspace)?;
|
|
3398
|
+
let live_sibling = raw
|
|
3399
|
+
.get("teams")
|
|
3400
|
+
.and_then(Value::as_object)
|
|
3401
|
+
.is_some_and(|teams| {
|
|
3402
|
+
teams
|
|
3403
|
+
.iter()
|
|
3404
|
+
.any(|(key, team)| key.as_str() != stopped_key && team_has_running_agent(team))
|
|
3405
|
+
});
|
|
3406
|
+
if live_sibling {
|
|
3407
|
+
Ok("scoped_live_sibling_present")
|
|
3408
|
+
} else {
|
|
3409
|
+
Ok("scoped_last_live_team")
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3373
3413
|
fn mark_matching_session_teams_stopped(
|
|
3374
3414
|
state: &mut Value,
|
|
3375
3415
|
session_name: Option<&crate::transport::SessionName>,
|
|
@@ -3915,10 +3955,15 @@ pub mod diagnose_port {
|
|
|
3915
3955
|
"pid": m.pid.get(),
|
|
3916
3956
|
"protocol_version": m.protocol_version,
|
|
3917
3957
|
"message_store_schema_version": m.message_store_schema_version,
|
|
3958
|
+
"binary_path": m.binary_path,
|
|
3959
|
+
"binary_version": m.binary_version,
|
|
3918
3960
|
"source": m.source,
|
|
3919
3961
|
"updated_at": m.updated_at,
|
|
3920
3962
|
})),
|
|
3921
3963
|
"metadata_ok": health.metadata_ok,
|
|
3964
|
+
"metadata_mismatch_reason": health.metadata_mismatch_reason,
|
|
3965
|
+
"binary_path": health.current_binary_identity.binary_path,
|
|
3966
|
+
"binary_version": health.current_binary_identity.binary_version,
|
|
3922
3967
|
"schema_ok": health.schema.ok,
|
|
3923
3968
|
"schema_error": health.schema.error.map(|e| format!("{e:?}")),
|
|
3924
3969
|
"schema": {
|
|
@@ -4060,79 +4105,15 @@ pub mod leader_port {
|
|
|
4060
4105
|
source: &str,
|
|
4061
4106
|
response: &mut Value,
|
|
4062
4107
|
) {
|
|
4063
|
-
let
|
|
4064
|
-
return;
|
|
4065
|
-
};
|
|
4066
|
-
let team_key = match team.filter(|t| !t.is_empty()) {
|
|
4067
|
-
Some(t) => t.to_string(),
|
|
4068
|
-
None => crate::state::projection::team_state_key(&state),
|
|
4069
|
-
};
|
|
4070
|
-
let receiver = state
|
|
4071
|
-
.get("teams")
|
|
4072
|
-
.and_then(|v| v.as_object())
|
|
4073
|
-
.and_then(|teams| teams.get(&team_key))
|
|
4074
|
-
.and_then(|t| t.get("leader_receiver"))
|
|
4075
|
-
.or_else(|| state.get("leader_receiver"))
|
|
4076
|
-
.cloned();
|
|
4077
|
-
let Some(receiver) = receiver else {
|
|
4078
|
-
return;
|
|
4079
|
-
};
|
|
4080
|
-
let transport_kind = receiver
|
|
4081
|
-
.get("transport_kind")
|
|
4082
|
-
.and_then(Value::as_str)
|
|
4083
|
-
.unwrap_or("direct_tmux")
|
|
4084
|
-
.to_string();
|
|
4085
|
-
let owner_epoch = receiver
|
|
4086
|
-
.get("owner_epoch")
|
|
4087
|
-
.and_then(Value::as_u64)
|
|
4088
|
-
.or_else(|| {
|
|
4089
|
-
state
|
|
4090
|
-
.get("teams")
|
|
4091
|
-
.and_then(|v| v.as_object())
|
|
4092
|
-
.and_then(|teams| teams.get(&team_key))
|
|
4093
|
-
.and_then(|t| t.get("owner_epoch"))
|
|
4094
|
-
.and_then(Value::as_u64)
|
|
4095
|
-
})
|
|
4096
|
-
.unwrap_or(0);
|
|
4097
|
-
let entry = crate::leader::registry::build_entry(
|
|
4108
|
+
let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
|
|
4098
4109
|
workspace,
|
|
4099
|
-
|
|
4100
|
-
&transport_kind,
|
|
4101
|
-
receiver,
|
|
4102
|
-
owner_epoch,
|
|
4110
|
+
team,
|
|
4103
4111
|
source,
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
let event_log = crate::event_log::EventLog::new(workspace);
|
|
4107
|
-
let write_result = crate::leader::registry::write_entry_best_effort(&entry);
|
|
4108
|
-
let registry_status = match &write_result {
|
|
4109
|
-
Some(path) => {
|
|
4110
|
-
let _ = event_log.write(
|
|
4111
|
-
crate::leader::registry::EVENT_REGISTERED,
|
|
4112
|
-
json!({
|
|
4113
|
-
"path": path.display().to_string(),
|
|
4114
|
-
"team_key": team_key,
|
|
4115
|
-
"workspace_hash": entry.workspace_hash,
|
|
4116
|
-
"source": source,
|
|
4117
|
-
"owner_epoch": entry.owner_epoch,
|
|
4118
|
-
}),
|
|
4119
|
-
);
|
|
4120
|
-
json!({"status": "registered", "path": path.display().to_string()})
|
|
4121
|
-
}
|
|
4122
|
-
None => {
|
|
4123
|
-
let _ = event_log.write(
|
|
4124
|
-
crate::leader::registry::EVENT_WRITE_FAILED,
|
|
4125
|
-
json!({
|
|
4126
|
-
"team_key": team_key,
|
|
4127
|
-
"workspace_hash": entry.workspace_hash,
|
|
4128
|
-
"source": source,
|
|
4129
|
-
}),
|
|
4130
|
-
);
|
|
4131
|
-
json!({"status": "write_failed"})
|
|
4132
|
-
}
|
|
4112
|
+
) else {
|
|
4113
|
+
return;
|
|
4133
4114
|
};
|
|
4134
4115
|
if let Some(obj) = response.as_object_mut() {
|
|
4135
|
-
obj.insert("leader_registry".to_string(),
|
|
4116
|
+
obj.insert("leader_registry".to_string(), outcome.response_json());
|
|
4136
4117
|
}
|
|
4137
4118
|
}
|
|
4138
4119
|
|
|
@@ -1151,10 +1151,15 @@ use rusqlite::params;
|
|
|
1151
1151
|
"pid": m.pid.get(),
|
|
1152
1152
|
"protocol_version": m.protocol_version,
|
|
1153
1153
|
"message_store_schema_version": m.message_store_schema_version,
|
|
1154
|
+
"binary_path": m.binary_path,
|
|
1155
|
+
"binary_version": m.binary_version,
|
|
1154
1156
|
"source": m.source,
|
|
1155
1157
|
"updated_at": m.updated_at,
|
|
1156
1158
|
})),
|
|
1157
1159
|
"metadata_ok": health.metadata_ok,
|
|
1160
|
+
"metadata_mismatch_reason": health.metadata_mismatch_reason,
|
|
1161
|
+
"binary_path": health.current_binary_identity.binary_path,
|
|
1162
|
+
"binary_version": health.current_binary_identity.binary_version,
|
|
1158
1163
|
"schema_ok": health.schema.ok,
|
|
1159
1164
|
"schema_error": health.schema.error.map(|e| format!("{e:?}")),
|
|
1160
1165
|
"schema": {
|
|
@@ -57,20 +57,30 @@ use super::*;
|
|
|
57
57
|
let _ = std::fs::remove_dir_all(&ws);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// ── status #3: coordinator = coordinator_health(ws) FULL
|
|
61
|
-
//
|
|
62
|
-
// RUST mod.rs:105-112 emits a 6-key subset {status,ok,pid,metadata_ok,schema_ok,schema_version}
|
|
63
|
-
// (no metadata/schema_error; flat schema_version instead of nested schema). RED on key-order. ──────
|
|
60
|
+
// ── status #3: coordinator = coordinator_health(ws) FULL shape in golden order ──────────────────
|
|
61
|
+
// 0.5.18 adds binary identity diagnostics to expose stale coordinator rotation reasons.
|
|
64
62
|
#[test]
|
|
65
|
-
fn
|
|
63
|
+
fn status_coordinator_is_full_health_identity_shape() {
|
|
66
64
|
let ws = tmp_workspace();
|
|
67
65
|
let v = status_port::status(&ws, false, true).expect("status");
|
|
68
66
|
let coord = v["coordinator"].as_object().expect("coordinator dict");
|
|
69
67
|
let order: Vec<&str> = coord.keys().map(String::as_str).collect();
|
|
70
68
|
assert_eq!(
|
|
71
69
|
order,
|
|
72
|
-
vec![
|
|
73
|
-
|
|
70
|
+
vec![
|
|
71
|
+
"ok",
|
|
72
|
+
"status",
|
|
73
|
+
"pid",
|
|
74
|
+
"metadata",
|
|
75
|
+
"metadata_ok",
|
|
76
|
+
"metadata_mismatch_reason",
|
|
77
|
+
"binary_path",
|
|
78
|
+
"binary_version",
|
|
79
|
+
"schema_ok",
|
|
80
|
+
"schema_error",
|
|
81
|
+
"schema"
|
|
82
|
+
],
|
|
83
|
+
"golden coordinator_health insertion order with 0.5.18 identity fields; got {order:?}"
|
|
74
84
|
);
|
|
75
85
|
assert!(
|
|
76
86
|
coord.get("schema_version").is_none(),
|
|
@@ -61,18 +61,14 @@ pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
|
|
|
61
61
|
// seed-state trap where writing active_team_key to state ahead
|
|
62
62
|
// of coord spawn made downstream launch code think "existing
|
|
63
63
|
// runtime, use restart" and skip spec compile.
|
|
64
|
-
let team_key_from_state = args
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.and_then(|v| v.as_str())
|
|
73
|
-
.filter(|s| !s.is_empty())
|
|
74
|
-
.map(str::to_string)
|
|
75
|
-
});
|
|
64
|
+
let team_key_from_state = args.team_key.clone().filter(|s| !s.is_empty()).or_else(|| {
|
|
65
|
+
state
|
|
66
|
+
.as_ref()
|
|
67
|
+
.and_then(|s| s.get("active_team_key"))
|
|
68
|
+
.and_then(|v| v.as_str())
|
|
69
|
+
.filter(|s| !s.is_empty())
|
|
70
|
+
.map(str::to_string)
|
|
71
|
+
});
|
|
76
72
|
let mut factory_input = crate::transport_factory::TransportFactoryInput::new(
|
|
77
73
|
args.workspace.as_path(),
|
|
78
74
|
crate::transport_factory::TransportPurpose::Coordinator,
|
|
@@ -104,7 +100,8 @@ pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
|
|
|
104
100
|
args.workspace.clone(),
|
|
105
101
|
Box::new(RealProviderRegistry),
|
|
106
102
|
Box::new(sel.backend),
|
|
107
|
-
)
|
|
103
|
+
)
|
|
104
|
+
.with_team_key(team_key_from_state.clone());
|
|
108
105
|
return run_daemon_with_coordinator_and_boot_tmux(&args, &coord, Some(metadata));
|
|
109
106
|
}
|
|
110
107
|
};
|
|
@@ -120,7 +117,8 @@ pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
|
|
|
120
117
|
args.workspace.clone(),
|
|
121
118
|
Box::new(RealProviderRegistry),
|
|
122
119
|
resolved.backend,
|
|
123
|
-
)
|
|
120
|
+
)
|
|
121
|
+
.with_team_key(team_key_from_state.clone());
|
|
124
122
|
run_daemon_with_coordinator_and_boot_tmux(&args, &coordinator, Some(tmux_metadata))
|
|
125
123
|
}
|
|
126
124
|
|
|
@@ -147,11 +145,14 @@ fn run_daemon_with_coordinator_and_boot_tmux(
|
|
|
147
145
|
let pid = Pid::new(std::process::id());
|
|
148
146
|
std::fs::write(coordinator_pid_path(&args.workspace), pid.to_string())?;
|
|
149
147
|
write_coordinator_metadata(&args.workspace, pid, MetadataSource::Boot)?;
|
|
148
|
+
let binary_identity = crate::coordinator::current_coordinator_binary_identity();
|
|
150
149
|
|
|
151
150
|
let event_log = EventLog::new(args.workspace.as_path());
|
|
152
151
|
let mut boot_event = serde_json::json!({
|
|
153
152
|
"workspace": args.workspace.as_path().to_string_lossy(),
|
|
154
153
|
"once": args.once,
|
|
154
|
+
"binary_path": binary_identity.binary_path,
|
|
155
|
+
"binary_version": binary_identity.binary_version,
|
|
155
156
|
});
|
|
156
157
|
if let Some(metadata) = tmux_metadata {
|
|
157
158
|
if let Some(object) = boot_event.as_object_mut() {
|
|
@@ -13,7 +13,8 @@ use thiserror::Error;
|
|
|
13
13
|
use crate::message_store::MessageStore;
|
|
14
14
|
|
|
15
15
|
use super::types::{
|
|
16
|
-
CoordinatorHealthStatus, CoordinatorMetadata,
|
|
16
|
+
CoordinatorBinaryIdentity, CoordinatorHealthStatus, CoordinatorMetadata,
|
|
17
|
+
CoordinatorMetadataMismatchReason, HealthReport, MetadataSource, Pid, SchemaError,
|
|
17
18
|
SchemaHealth, StartError, StartOutcome, StartReport, StopError, StopOutcome, StopReport,
|
|
18
19
|
WatchCursor, WorkspacePath, PROTOCOL_VERSION, ROTATION_MARKER,
|
|
19
20
|
};
|
|
@@ -27,6 +28,7 @@ use super::types::{
|
|
|
27
28
|
/// `coordinator_health`(`lifecycle.py:38-46`):`running ∧ metadata_ok ∧ schema_ok` → typed report.
|
|
28
29
|
pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
|
|
29
30
|
let schema = message_store_schema_health(workspace);
|
|
31
|
+
let current_binary_identity = current_coordinator_binary_identity();
|
|
30
32
|
let pid_path = coordinator_pid_path(workspace);
|
|
31
33
|
let pid = read_pid_file(&pid_path);
|
|
32
34
|
let status = match pid {
|
|
@@ -38,7 +40,14 @@ pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
|
|
|
38
40
|
None => CoordinatorHealthStatus::Missing,
|
|
39
41
|
};
|
|
40
42
|
let metadata = read_coordinator_metadata(workspace);
|
|
41
|
-
let
|
|
43
|
+
let metadata_mismatch = pid
|
|
44
|
+
.map(|p| coordinator_metadata_mismatch_reason_with_identity(
|
|
45
|
+
metadata.as_ref(),
|
|
46
|
+
p,
|
|
47
|
+
¤t_binary_identity,
|
|
48
|
+
))
|
|
49
|
+
.unwrap_or(Some(CoordinatorMetadataMismatchReason::MetadataMissing));
|
|
50
|
+
let metadata_ok = metadata_mismatch.is_none();
|
|
42
51
|
let running = matches!(status, CoordinatorHealthStatus::Running);
|
|
43
52
|
HealthReport {
|
|
44
53
|
ok: running && metadata_ok && schema.ok,
|
|
@@ -46,6 +55,8 @@ pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
|
|
|
46
55
|
pid,
|
|
47
56
|
metadata,
|
|
48
57
|
metadata_ok,
|
|
58
|
+
metadata_mismatch_reason: metadata_mismatch.map(|reason| reason.as_str().to_string()),
|
|
59
|
+
current_binary_identity,
|
|
49
60
|
schema,
|
|
50
61
|
}
|
|
51
62
|
}
|
|
@@ -69,11 +80,16 @@ pub fn start_coordinator_with_team(
|
|
|
69
80
|
team_key: Option<&str>,
|
|
70
81
|
) -> Result<StartReport, StartError> {
|
|
71
82
|
let health = coordinator_health(workspace);
|
|
83
|
+
let identity = health.current_binary_identity.clone();
|
|
72
84
|
if health.ok {
|
|
73
85
|
return Ok(StartReport {
|
|
74
86
|
ok: true,
|
|
75
87
|
pid: health.pid,
|
|
76
88
|
status: StartOutcome::AlreadyRunning,
|
|
89
|
+
previous_pid: None,
|
|
90
|
+
binary_path: Some(identity.binary_path),
|
|
91
|
+
binary_version: Some(identity.binary_version),
|
|
92
|
+
rotation_reason: None,
|
|
77
93
|
log: Some(coordinator_log_path(workspace)),
|
|
78
94
|
schema_error: None,
|
|
79
95
|
action: None,
|
|
@@ -84,12 +100,54 @@ pub fn start_coordinator_with_team(
|
|
|
84
100
|
ok: false,
|
|
85
101
|
pid: health.pid,
|
|
86
102
|
status: StartOutcome::SchemaIncompatible,
|
|
103
|
+
previous_pid: None,
|
|
104
|
+
binary_path: Some(identity.binary_path),
|
|
105
|
+
binary_version: Some(identity.binary_version),
|
|
106
|
+
rotation_reason: health.metadata_mismatch_reason,
|
|
87
107
|
log: None,
|
|
88
108
|
schema_error: health.schema.error,
|
|
89
109
|
action: health.schema.action,
|
|
90
110
|
});
|
|
91
111
|
}
|
|
92
|
-
|
|
112
|
+
let rotation_reason = if matches!(health.status, CoordinatorHealthStatus::Running)
|
|
113
|
+
&& !health.metadata_ok
|
|
114
|
+
{
|
|
115
|
+
health.metadata_mismatch_reason.clone()
|
|
116
|
+
} else {
|
|
117
|
+
None
|
|
118
|
+
};
|
|
119
|
+
if matches!(health.status, CoordinatorHealthStatus::Running) && !health.metadata_ok {
|
|
120
|
+
crate::event_log::EventLog::new(workspace.as_path()).write(
|
|
121
|
+
"coordinator.rotation_required",
|
|
122
|
+
serde_json::json!({
|
|
123
|
+
"old_pid": health.pid.map(|pid| pid.get()),
|
|
124
|
+
"reason": rotation_reason.clone(),
|
|
125
|
+
"old_binary_path": health
|
|
126
|
+
.metadata
|
|
127
|
+
.as_ref()
|
|
128
|
+
.and_then(|metadata| metadata.binary_path.clone()),
|
|
129
|
+
"old_binary_version": health
|
|
130
|
+
.metadata
|
|
131
|
+
.as_ref()
|
|
132
|
+
.and_then(|metadata| metadata.binary_version.clone()),
|
|
133
|
+
"current_binary_path": identity.binary_path.clone(),
|
|
134
|
+
"current_binary_version": identity.binary_version.clone(),
|
|
135
|
+
}),
|
|
136
|
+
)?;
|
|
137
|
+
if health.pid.map(|pid| pid.get()) == Some(std::process::id()) {
|
|
138
|
+
return Ok(StartReport {
|
|
139
|
+
ok: false,
|
|
140
|
+
pid: health.pid,
|
|
141
|
+
status: StartOutcome::RestartIncompatibleStopFailed,
|
|
142
|
+
previous_pid: health.pid,
|
|
143
|
+
binary_path: Some(identity.binary_path),
|
|
144
|
+
binary_version: Some(identity.binary_version),
|
|
145
|
+
rotation_reason,
|
|
146
|
+
log: None,
|
|
147
|
+
schema_error: None,
|
|
148
|
+
action: Some("refusing to rotate coordinator metadata that points at the caller process".to_string()),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
93
151
|
match stop_coordinator(workspace) {
|
|
94
152
|
Ok(stop) if stop.ok => {}
|
|
95
153
|
Ok(_) | Err(_) => {
|
|
@@ -97,6 +155,10 @@ pub fn start_coordinator_with_team(
|
|
|
97
155
|
ok: false,
|
|
98
156
|
pid: health.pid,
|
|
99
157
|
status: StartOutcome::RestartIncompatibleStopFailed,
|
|
158
|
+
previous_pid: health.pid,
|
|
159
|
+
binary_path: Some(identity.binary_path),
|
|
160
|
+
binary_version: Some(identity.binary_version),
|
|
161
|
+
rotation_reason,
|
|
100
162
|
log: None,
|
|
101
163
|
schema_error: None,
|
|
102
164
|
action: None,
|
|
@@ -131,10 +193,19 @@ pub fn start_coordinator_with_team(
|
|
|
131
193
|
let pid = Pid::new(child.id());
|
|
132
194
|
std::fs::write(coordinator_pid_path(workspace), pid.to_string())?;
|
|
133
195
|
write_coordinator_metadata(workspace, pid, MetadataSource::Start)?;
|
|
196
|
+
let status = if rotation_reason.is_some() {
|
|
197
|
+
StartOutcome::StartedAfterRotation
|
|
198
|
+
} else {
|
|
199
|
+
StartOutcome::Started
|
|
200
|
+
};
|
|
134
201
|
Ok(StartReport {
|
|
135
202
|
ok: true,
|
|
136
203
|
pid: Some(pid),
|
|
137
|
-
status
|
|
204
|
+
status,
|
|
205
|
+
previous_pid: health.pid,
|
|
206
|
+
binary_path: Some(identity.binary_path),
|
|
207
|
+
binary_version: Some(identity.binary_version),
|
|
208
|
+
rotation_reason,
|
|
138
209
|
log: Some(log_path),
|
|
139
210
|
schema_error: None,
|
|
140
211
|
action: None,
|
|
@@ -206,6 +277,15 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
206
277
|
pid: Some(pid),
|
|
207
278
|
});
|
|
208
279
|
}
|
|
280
|
+
if pid.get() == std::process::id() {
|
|
281
|
+
remove_file_if_exists(&pid_path)?;
|
|
282
|
+
remove_file_if_exists(&coordinator_meta_path(workspace))?;
|
|
283
|
+
return Ok(StopReport {
|
|
284
|
+
ok: true,
|
|
285
|
+
status: StopOutcome::Stopped,
|
|
286
|
+
pid: Some(pid),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
209
289
|
if !terminate_pid(pid) {
|
|
210
290
|
return Ok(StopReport {
|
|
211
291
|
ok: false,
|
|
@@ -499,15 +579,63 @@ pub fn read_coordinator_metadata(workspace: &WorkspacePath) -> Option<Coordinato
|
|
|
499
579
|
serde_json::from_str(&text).ok()
|
|
500
580
|
}
|
|
501
581
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
582
|
+
pub fn current_coordinator_binary_identity() -> CoordinatorBinaryIdentity {
|
|
583
|
+
let binary_path = std::env::current_exe()
|
|
584
|
+
.map(|path| path.canonicalize().unwrap_or(path))
|
|
585
|
+
.map(|path| path.to_string_lossy().into_owned())
|
|
586
|
+
.unwrap_or_else(|_| "<unknown>".to_string());
|
|
587
|
+
CoordinatorBinaryIdentity {
|
|
588
|
+
binary_path,
|
|
589
|
+
binary_version: crate::packaging::Version::current().as_str().to_string(),
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/// `coordinator_metadata_ok` now includes daemon binary identity in addition
|
|
594
|
+
/// to the original pid/protocol/schema tuple.
|
|
505
595
|
pub fn coordinator_metadata_ok(metadata: Option<&CoordinatorMetadata>, pid: Pid) -> bool {
|
|
506
|
-
metadata.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
596
|
+
coordinator_metadata_mismatch_reason(metadata, pid).is_none()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
pub fn coordinator_metadata_mismatch_reason(
|
|
600
|
+
metadata: Option<&CoordinatorMetadata>,
|
|
601
|
+
pid: Pid,
|
|
602
|
+
) -> Option<CoordinatorMetadataMismatchReason> {
|
|
603
|
+
let identity = current_coordinator_binary_identity();
|
|
604
|
+
coordinator_metadata_mismatch_reason_with_identity(metadata, pid, &identity)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
fn coordinator_metadata_mismatch_reason_with_identity(
|
|
608
|
+
metadata: Option<&CoordinatorMetadata>,
|
|
609
|
+
pid: Pid,
|
|
610
|
+
identity: &CoordinatorBinaryIdentity,
|
|
611
|
+
) -> Option<CoordinatorMetadataMismatchReason> {
|
|
612
|
+
let Some(metadata) = metadata else {
|
|
613
|
+
return Some(CoordinatorMetadataMismatchReason::MetadataMissing);
|
|
614
|
+
};
|
|
615
|
+
if metadata.pid != pid {
|
|
616
|
+
return Some(CoordinatorMetadataMismatchReason::PidMismatch);
|
|
617
|
+
}
|
|
618
|
+
if metadata.protocol_version != PROTOCOL_VERSION {
|
|
619
|
+
return Some(CoordinatorMetadataMismatchReason::ProtocolVersionMismatch);
|
|
620
|
+
}
|
|
621
|
+
if metadata.message_store_schema_version != crate::db::schema::SCHEMA_VERSION {
|
|
622
|
+
return Some(CoordinatorMetadataMismatchReason::MessageStoreSchemaVersionMismatch);
|
|
623
|
+
}
|
|
624
|
+
let Some(binary_version) = metadata.binary_version.as_deref().filter(|value| !value.is_empty())
|
|
625
|
+
else {
|
|
626
|
+
return Some(CoordinatorMetadataMismatchReason::BinaryIdentityMissing);
|
|
627
|
+
};
|
|
628
|
+
let Some(binary_path) = metadata.binary_path.as_deref().filter(|value| !value.is_empty())
|
|
629
|
+
else {
|
|
630
|
+
return Some(CoordinatorMetadataMismatchReason::BinaryIdentityMissing);
|
|
631
|
+
};
|
|
632
|
+
if binary_version != identity.binary_version {
|
|
633
|
+
return Some(CoordinatorMetadataMismatchReason::BinaryVersionMismatch);
|
|
634
|
+
}
|
|
635
|
+
if binary_path != identity.binary_path {
|
|
636
|
+
return Some(CoordinatorMetadataMismatchReason::BinaryPathMismatch);
|
|
637
|
+
}
|
|
638
|
+
None
|
|
511
639
|
}
|
|
512
640
|
|
|
513
641
|
/// `write_coordinator_metadata`(`metadata.py:46-61`)。写 `coordinator.json`(pretty indent=2),
|
|
@@ -525,6 +653,8 @@ pub fn write_coordinator_metadata(
|
|
|
525
653
|
pid,
|
|
526
654
|
protocol_version: PROTOCOL_VERSION,
|
|
527
655
|
message_store_schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
656
|
+
binary_path: Some(current_coordinator_binary_identity().binary_path),
|
|
657
|
+
binary_version: Some(crate::packaging::Version::current().as_str().to_string()),
|
|
528
658
|
source,
|
|
529
659
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
|
530
660
|
};
|
|
@@ -38,6 +38,7 @@ fn status_enums_serialize_to_exact_python_strings() {
|
|
|
38
38
|
(serde_json::to_string(&StartOutcome::RestartIncompatibleStopFailed).unwrap(), "\"restart_incompatible_stop_failed\""),
|
|
39
39
|
(serde_json::to_string(&StartOutcome::SchemaIncompatible).unwrap(), "\"schema_incompatible\""),
|
|
40
40
|
(serde_json::to_string(&StartOutcome::Started).unwrap(), "\"started\""),
|
|
41
|
+
(serde_json::to_string(&StartOutcome::StartedAfterRotation).unwrap(), "\"started_after_rotation\""),
|
|
41
42
|
(serde_json::to_string(&StopOutcome::Missing).unwrap(), "\"missing\""),
|
|
42
43
|
(serde_json::to_string(&StopOutcome::InvalidPidRemoved).unwrap(), "\"invalid_pid_removed\""),
|
|
43
44
|
(serde_json::to_string(&StopOutcome::KillFailed).unwrap(), "\"kill_failed\""),
|
|
@@ -173,6 +174,9 @@ fn coordinator_metadata_json_field_names_are_stable() {
|
|
|
173
174
|
assert_eq!(json["pid"], 123);
|
|
174
175
|
assert_eq!(json["protocol_version"], 2);
|
|
175
176
|
assert_eq!(json["message_store_schema_version"], 3);
|
|
177
|
+
let identity = current_coordinator_binary_identity();
|
|
178
|
+
assert_eq!(json["binary_path"], identity.binary_path);
|
|
179
|
+
assert_eq!(json["binary_version"], identity.binary_version);
|
|
176
180
|
assert_eq!(json["source"], "boot");
|
|
177
181
|
assert_eq!(json["updated_at"], "2026-06-02T00:00:00+00:00");
|
|
178
182
|
}
|
|
@@ -13,10 +13,13 @@ fn ws() -> WorkspacePath {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
fn meta(pid: u32, proto: u32, schema: i64) -> CoordinatorMetadata {
|
|
16
|
+
let identity = current_coordinator_binary_identity();
|
|
16
17
|
CoordinatorMetadata {
|
|
17
18
|
pid: Pid(pid),
|
|
18
19
|
protocol_version: proto,
|
|
19
20
|
message_store_schema_version: schema,
|
|
21
|
+
binary_path: Some(identity.binary_path),
|
|
22
|
+
binary_version: Some(identity.binary_version),
|
|
20
23
|
source: MetadataSource::Boot,
|
|
21
24
|
updated_at: "2026-06-02T00:00:00+00:00".to_string(),
|
|
22
25
|
}
|