@team-agent/installer 0.5.17 → 0.5.19
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 +2 -1
- package/crates/team-agent/src/cli/diagnose.rs +185 -0
- package/crates/team-agent/src/cli/mod.rs +46 -1
- 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/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 +34 -20
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +133 -11
- package/crates/team-agent/src/lifecycle/types.rs +54 -0
- package/crates/team-agent/src/messaging/results.rs +7 -0
- package/package.json +4 -4
|
@@ -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
|
}
|
|
@@ -107,20 +107,20 @@ pub enum TickError {
|
|
|
107
107
|
/// returns `None` and the tick falls back to the raw state — preserving
|
|
108
108
|
/// behavior for legacy single-team workspaces and tests that don't seed
|
|
109
109
|
/// `teams.<key>`. Sibling teams under `state.teams.*` are NOT touched.
|
|
110
|
-
fn coordinator_team_scoped_state(
|
|
110
|
+
fn coordinator_team_scoped_state(
|
|
111
|
+
workspace: &std::path::Path,
|
|
112
|
+
raw_state: &Value,
|
|
113
|
+
daemon_team_key: Option<&str>,
|
|
114
|
+
) -> Option<Value> {
|
|
115
|
+
let teams = raw_state.get("teams").and_then(Value::as_object)?;
|
|
111
116
|
let active = raw_state
|
|
112
117
|
.get("active_team_key")
|
|
113
118
|
.and_then(Value::as_str)
|
|
114
|
-
.filter(|s| !s.is_empty())
|
|
115
|
-
let
|
|
116
|
-
.
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
.unwrap_or(false);
|
|
120
|
-
if !has_team_entry {
|
|
121
|
-
return None;
|
|
122
|
-
}
|
|
123
|
-
crate::state::projection::select_runtime_state(workspace, Some(active)).ok()
|
|
119
|
+
.filter(|s| !s.is_empty());
|
|
120
|
+
let selected = daemon_team_key
|
|
121
|
+
.filter(|key| !key.is_empty() && teams.contains_key(*key))
|
|
122
|
+
.or_else(|| active.filter(|key| teams.contains_key(*key)))?;
|
|
123
|
+
crate::state::projection::select_runtime_state(workspace, Some(selected)).ok()
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// ===========================================================================
|
|
@@ -149,6 +149,10 @@ pub struct Coordinator {
|
|
|
149
149
|
/// transport 控制面(tmux session 存活探测等;经 trait 注入,可 mock)。
|
|
150
150
|
#[allow(dead_code)]
|
|
151
151
|
transport: Box<dyn crate::transport::Transport>,
|
|
152
|
+
/// Daemon CLI-selected team key. When present, tick projection uses this
|
|
153
|
+
/// instead of a stale root active_team_key.
|
|
154
|
+
#[allow(dead_code)]
|
|
155
|
+
daemon_team_key: Option<String>,
|
|
152
156
|
/// bug-084 save 注入钩。`None` ⇔ 真实 `state::save_runtime_state`。
|
|
153
157
|
#[allow(dead_code)]
|
|
154
158
|
save_hook: Option<SaveHook>,
|
|
@@ -168,11 +172,17 @@ impl Coordinator {
|
|
|
168
172
|
workspace,
|
|
169
173
|
provider_registry,
|
|
170
174
|
transport,
|
|
175
|
+
daemon_team_key: None,
|
|
171
176
|
save_hook: None,
|
|
172
177
|
order_recorder: None,
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
pub(crate) fn with_team_key(mut self, team_key: Option<String>) -> Self {
|
|
182
|
+
self.daemon_team_key = team_key.filter(|key| !key.is_empty());
|
|
183
|
+
self
|
|
184
|
+
}
|
|
185
|
+
|
|
176
186
|
/// 测试装配:直接构出 `Coordinator`(不经 `new` 的 `unimplemented!()`),注入 mock
|
|
177
187
|
/// transport + mock provider registry + 可选 save 注入钩 + ORDER 探针。**纯 test-support
|
|
178
188
|
/// 脚手架**(真实 impl,非 `unimplemented!()`):它只装配字段,不执行任何 daemon 逻辑;
|
|
@@ -189,6 +199,7 @@ impl Coordinator {
|
|
|
189
199
|
workspace,
|
|
190
200
|
provider_registry,
|
|
191
201
|
transport,
|
|
202
|
+
daemon_team_key: None,
|
|
192
203
|
save_hook,
|
|
193
204
|
order_recorder,
|
|
194
205
|
}
|
|
@@ -224,8 +235,12 @@ impl Coordinator {
|
|
|
224
235
|
// `team-prerelease-040-round3b`) and emit `coordinator.session_missing`
|
|
225
236
|
// even though the right session is alive. Fall back to raw state when
|
|
226
237
|
// no team scope can be derived (legacy single-team workspaces).
|
|
227
|
-
let mut state = coordinator_team_scoped_state(
|
|
228
|
-
.
|
|
238
|
+
let mut state = coordinator_team_scoped_state(
|
|
239
|
+
self.workspace.as_path(),
|
|
240
|
+
&raw_state,
|
|
241
|
+
self.daemon_team_key.as_deref(),
|
|
242
|
+
)
|
|
243
|
+
.unwrap_or(raw_state);
|
|
229
244
|
let store = crate::message_store::MessageStore::open(self.workspace.as_path())?;
|
|
230
245
|
let event_log = EventLog::new(self.workspace.as_path());
|
|
231
246
|
increment_coordinator_tick_iteration_count(&self.workspace);
|
|
@@ -1264,27 +1279,7 @@ impl Coordinator {
|
|
|
1264
1279
|
/// `coordinator_health`(`lifecycle.py:26`)。pid + meta + schema 三合一健康。
|
|
1265
1280
|
/// doctor / start 前置调它。`ok = running ∧ metadata_ok ∧ schema_ok`。
|
|
1266
1281
|
pub fn health(&self) -> Result<HealthReport, TickError> {
|
|
1267
|
-
|
|
1268
|
-
let pid_path = coordinator_pid_path(&self.workspace);
|
|
1269
|
-
let pid = read_pid_file(&pid_path);
|
|
1270
|
-
let (status, running) = match pid {
|
|
1271
|
-
Some(pid) if pid_is_running(pid).unwrap_or(false) => {
|
|
1272
|
-
(CoordinatorHealthStatus::Running, true)
|
|
1273
|
-
}
|
|
1274
|
-
Some(_) => (CoordinatorHealthStatus::Stale, false),
|
|
1275
|
-
None if pid_path.exists() => (CoordinatorHealthStatus::InvalidPid, false),
|
|
1276
|
-
None => (CoordinatorHealthStatus::Missing, false),
|
|
1277
|
-
};
|
|
1278
|
-
let metadata = read_coordinator_metadata(&self.workspace);
|
|
1279
|
-
let metadata_ok = pid.is_some_and(|p| coordinator_metadata_ok(metadata.as_ref(), p));
|
|
1280
|
-
Ok(HealthReport {
|
|
1281
|
-
ok: running && metadata_ok && schema.ok,
|
|
1282
|
-
status,
|
|
1283
|
-
pid,
|
|
1284
|
-
metadata,
|
|
1285
|
-
metadata_ok,
|
|
1286
|
-
schema,
|
|
1287
|
-
})
|
|
1282
|
+
Ok(super::health::coordinator_health(&self.workspace))
|
|
1288
1283
|
}
|
|
1289
1284
|
|
|
1290
1285
|
/// `start_coordinator`(`lifecycle.py:49`)。幂等启动:已健康 no-op;metadata 不兼容先 stop 再起;
|
|
@@ -1292,40 +1287,7 @@ impl Coordinator {
|
|
|
1292
1287
|
/// Python 是 `python -m team_agent.coordinator`,`lifecycle.py:108`)。
|
|
1293
1288
|
/// **schema 兼容门**:三元任一不匹配 → restart_incompatible,**不可静默继续**(card §89)。
|
|
1294
1289
|
pub fn start(&self) -> Result<StartReport, StartError> {
|
|
1295
|
-
|
|
1296
|
-
.health()
|
|
1297
|
-
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
1298
|
-
if health.ok {
|
|
1299
|
-
return Ok(StartReport {
|
|
1300
|
-
ok: true,
|
|
1301
|
-
pid: health.pid,
|
|
1302
|
-
status: StartOutcome::AlreadyRunning,
|
|
1303
|
-
log: Some(coordinator_log_path(&self.workspace)),
|
|
1304
|
-
schema_error: None,
|
|
1305
|
-
action: None,
|
|
1306
|
-
});
|
|
1307
|
-
}
|
|
1308
|
-
if !health.schema.ok {
|
|
1309
|
-
return Ok(StartReport {
|
|
1310
|
-
ok: false,
|
|
1311
|
-
pid: health.pid,
|
|
1312
|
-
status: StartOutcome::SchemaIncompatible,
|
|
1313
|
-
log: None,
|
|
1314
|
-
schema_error: health.schema.error,
|
|
1315
|
-
action: health.schema.action,
|
|
1316
|
-
});
|
|
1317
|
-
}
|
|
1318
|
-
let pid = Pid::new(std::process::id());
|
|
1319
|
-
write_coordinator_metadata(&self.workspace, pid, MetadataSource::Start)?;
|
|
1320
|
-
std::fs::write(coordinator_pid_path(&self.workspace), pid.to_string())?;
|
|
1321
|
-
Ok(StartReport {
|
|
1322
|
-
ok: true,
|
|
1323
|
-
pid: Some(pid),
|
|
1324
|
-
status: StartOutcome::Started,
|
|
1325
|
-
log: Some(coordinator_log_path(&self.workspace)),
|
|
1326
|
-
schema_error: None,
|
|
1327
|
-
action: None,
|
|
1328
|
-
})
|
|
1290
|
+
super::health::start_coordinator(&self.workspace)
|
|
1329
1291
|
}
|
|
1330
1292
|
|
|
1331
1293
|
/// `stop_coordinator`(`lifecycle.py:229`)。SIGTERM + 清 pid/meta。pid 非整数 → 清文件返回。
|
|
@@ -2544,4 +2506,44 @@ mod u1_tests {
|
|
|
2544
2506
|
"capture_missing failure must be visible in events.jsonl; got {events}"
|
|
2545
2507
|
);
|
|
2546
2508
|
}
|
|
2509
|
+
|
|
2510
|
+
#[test]
|
|
2511
|
+
fn daemon_team_key_projection_beats_stale_root_active_team_key() {
|
|
2512
|
+
let dir = std::env::temp_dir().join(format!(
|
|
2513
|
+
"team-agent-daemon-team-key-{}-{}",
|
|
2514
|
+
std::process::id(),
|
|
2515
|
+
std::time::SystemTime::now()
|
|
2516
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
2517
|
+
.unwrap()
|
|
2518
|
+
.as_nanos()
|
|
2519
|
+
));
|
|
2520
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
2521
|
+
let raw = serde_json::json!({
|
|
2522
|
+
"active_team_key": "stale-b",
|
|
2523
|
+
"session_name": "team-stale-b",
|
|
2524
|
+
"teams": {
|
|
2525
|
+
"fresh-a": {
|
|
2526
|
+
"active_team_key": "fresh-a",
|
|
2527
|
+
"team_key": "fresh-a",
|
|
2528
|
+
"session_name": "team-fresh-a",
|
|
2529
|
+
"agents": {}
|
|
2530
|
+
},
|
|
2531
|
+
"stale-b": {
|
|
2532
|
+
"active_team_key": "stale-b",
|
|
2533
|
+
"team_key": "stale-b",
|
|
2534
|
+
"session_name": "team-stale-b",
|
|
2535
|
+
"agents": {}
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
});
|
|
2539
|
+
crate::state::persist::save_runtime_state(&dir, &raw).unwrap();
|
|
2540
|
+
|
|
2541
|
+
let selected =
|
|
2542
|
+
coordinator_team_scoped_state(&dir, &raw, Some("fresh-a")).expect("selected team");
|
|
2543
|
+
|
|
2544
|
+
assert_eq!(
|
|
2545
|
+
selected.get("session_name").and_then(Value::as_str),
|
|
2546
|
+
Some("team-fresh-a")
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2547
2549
|
}
|
|
@@ -112,6 +112,8 @@ pub enum StartOutcome {
|
|
|
112
112
|
SchemaIncompatible,
|
|
113
113
|
/// 正常 spawn(`lifecycle.py:121`)。
|
|
114
114
|
Started,
|
|
115
|
+
/// 当前 CLI 发现 live daemon 的 binary identity 过期,stop 后重新 spawn。
|
|
116
|
+
StartedAfterRotation,
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/// `stop_coordinator` 结果 status(`lifecycle.py:232,238,243,247`)。
|
|
@@ -170,11 +172,50 @@ pub struct CoordinatorMetadata {
|
|
|
170
172
|
pub pid: Pid,
|
|
171
173
|
pub protocol_version: u32,
|
|
172
174
|
pub message_store_schema_version: i64,
|
|
175
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
176
|
+
pub binary_path: Option<String>,
|
|
177
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
178
|
+
pub binary_version: Option<String>,
|
|
173
179
|
pub source: MetadataSource,
|
|
174
180
|
/// ISO8601(`datetime.now(timezone.utc).isoformat()`,`metadata.py:56`)。
|
|
175
181
|
pub updated_at: String,
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
/// Current coordinator daemon binary identity. Version comes from the compiled
|
|
185
|
+
/// package; path comes from the currently executing CLI binary, not PATH.
|
|
186
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
187
|
+
pub struct CoordinatorBinaryIdentity {
|
|
188
|
+
pub binary_path: String,
|
|
189
|
+
pub binary_version: String,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Stable machine-readable reason for coordinator metadata rejection.
|
|
193
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
194
|
+
#[serde(rename_all = "snake_case")]
|
|
195
|
+
pub enum CoordinatorMetadataMismatchReason {
|
|
196
|
+
MetadataMissing,
|
|
197
|
+
PidMismatch,
|
|
198
|
+
ProtocolVersionMismatch,
|
|
199
|
+
MessageStoreSchemaVersionMismatch,
|
|
200
|
+
BinaryIdentityMissing,
|
|
201
|
+
BinaryVersionMismatch,
|
|
202
|
+
BinaryPathMismatch,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
impl CoordinatorMetadataMismatchReason {
|
|
206
|
+
pub fn as_str(self) -> &'static str {
|
|
207
|
+
match self {
|
|
208
|
+
Self::MetadataMissing => "metadata_missing",
|
|
209
|
+
Self::PidMismatch => "pid_mismatch",
|
|
210
|
+
Self::ProtocolVersionMismatch => "protocol_version_mismatch",
|
|
211
|
+
Self::MessageStoreSchemaVersionMismatch => "message_store_schema_version_mismatch",
|
|
212
|
+
Self::BinaryIdentityMissing => "binary_identity_missing",
|
|
213
|
+
Self::BinaryVersionMismatch => "binary_version_mismatch",
|
|
214
|
+
Self::BinaryPathMismatch => "binary_path_mismatch",
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
178
219
|
// ===========================================================================
|
|
179
220
|
// SchemaHealth(lifecycle.py:197-226)
|
|
180
221
|
// ===========================================================================
|
|
@@ -244,6 +285,8 @@ pub struct HealthReport {
|
|
|
244
285
|
pub metadata: Option<CoordinatorMetadata>,
|
|
245
286
|
/// 三元全等(`coordinator_metadata_ok`,`metadata.py:37-43`)。
|
|
246
287
|
pub metadata_ok: bool,
|
|
288
|
+
pub metadata_mismatch_reason: Option<String>,
|
|
289
|
+
pub current_binary_identity: CoordinatorBinaryIdentity,
|
|
247
290
|
pub schema: SchemaHealth,
|
|
248
291
|
}
|
|
249
292
|
|
|
@@ -253,6 +296,10 @@ pub struct StartReport {
|
|
|
253
296
|
pub ok: bool,
|
|
254
297
|
pub pid: Option<Pid>,
|
|
255
298
|
pub status: StartOutcome,
|
|
299
|
+
pub previous_pid: Option<Pid>,
|
|
300
|
+
pub binary_path: Option<String>,
|
|
301
|
+
pub binary_version: Option<String>,
|
|
302
|
+
pub rotation_reason: Option<String>,
|
|
256
303
|
/// coordinator.log 路径(成功路径,`lifecycle.py:54/121`)。
|
|
257
304
|
pub log: Option<PathBuf>,
|
|
258
305
|
/// schema_incompatible 时的修复 hint / 失败原因。
|
|
@@ -9,7 +9,9 @@ use crate::cli::CliError;
|
|
|
9
9
|
use crate::coordinator::health::{
|
|
10
10
|
coordinator_metadata_ok, pid_is_running, read_coordinator_metadata, terminate_pid_tree,
|
|
11
11
|
};
|
|
12
|
-
use crate::coordinator::types::{
|
|
12
|
+
use crate::coordinator::types::{
|
|
13
|
+
CoordinatorMetadata, OrphanReason, Pid, WorkspacePath, PROTOCOL_VERSION,
|
|
14
|
+
};
|
|
13
15
|
// Phase 1d Batch 6: `TmuxBackend` import removed — all sites now use
|
|
14
16
|
// `transport_factory::tmux_socket_name_transport` for grep-visibility.
|
|
15
17
|
use crate::transport::{SessionName, Transport};
|
|
@@ -477,8 +479,7 @@ fn ps_command_rows() -> Vec<ProcessRow> {
|
|
|
477
479
|
Command::new("ps").args(["-axww", "-o", "pid=,command="]),
|
|
478
480
|
"ps_table",
|
|
479
481
|
None,
|
|
480
|
-
)
|
|
481
|
-
{
|
|
482
|
+
) {
|
|
482
483
|
Ok(output) if output.status.success() => output,
|
|
483
484
|
_ => return Vec::new(),
|
|
484
485
|
};
|
|
@@ -490,9 +491,7 @@ fn ps_command_rows() -> Vec<ProcessRow> {
|
|
|
490
491
|
|
|
491
492
|
fn parse_ps_command_line(line: &str) -> Option<ProcessRow> {
|
|
492
493
|
let line = line.trim_start();
|
|
493
|
-
let split = line
|
|
494
|
-
.find(char::is_whitespace)
|
|
495
|
-
.unwrap_or(line.len());
|
|
494
|
+
let split = line.find(char::is_whitespace).unwrap_or(line.len());
|
|
496
495
|
let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
|
|
497
496
|
let command = line.get(split..)?.trim().to_string();
|
|
498
497
|
Some(ProcessRow {
|
|
@@ -568,7 +567,7 @@ fn classify_workspace_orphan(workspace: &Path, pid: Pid) -> Option<OrphanReason>
|
|
|
568
567
|
}
|
|
569
568
|
let workspace_path = WorkspacePath::new(workspace.to_path_buf());
|
|
570
569
|
let metadata = read_coordinator_metadata(&workspace_path);
|
|
571
|
-
if
|
|
570
|
+
if coordinator_process_metadata_mismatch(metadata.as_ref(), pid) {
|
|
572
571
|
return Some(OrphanReason::MetadataMismatch);
|
|
573
572
|
}
|
|
574
573
|
if pid_is_running(pid).ok() == Some(false) {
|
|
@@ -577,6 +576,15 @@ fn classify_workspace_orphan(workspace: &Path, pid: Pid) -> Option<OrphanReason>
|
|
|
577
576
|
None
|
|
578
577
|
}
|
|
579
578
|
|
|
579
|
+
fn coordinator_process_metadata_mismatch(metadata: Option<&CoordinatorMetadata>, pid: Pid) -> bool {
|
|
580
|
+
let Some(metadata) = metadata else {
|
|
581
|
+
return false;
|
|
582
|
+
};
|
|
583
|
+
metadata.pid != pid
|
|
584
|
+
|| metadata.protocol_version != PROTOCOL_VERSION
|
|
585
|
+
|| metadata.message_store_schema_version != crate::db::schema::SCHEMA_VERSION
|
|
586
|
+
}
|
|
587
|
+
|
|
580
588
|
fn classify_workspace_without_pid(workspace: &Path) -> Option<OrphanReason> {
|
|
581
589
|
if !workspace.is_absolute() {
|
|
582
590
|
return None;
|
|
@@ -592,10 +600,7 @@ fn classify_workspace_without_pid(workspace: &Path) -> Option<OrphanReason> {
|
|
|
592
600
|
|
|
593
601
|
fn ephemeral_workspace_hint(workspace: &Path) -> Option<String> {
|
|
594
602
|
let text = workspace.to_string_lossy();
|
|
595
|
-
let patterns = [
|
|
596
|
-
"ta_doctor_comms_orphans-",
|
|
597
|
-
"team-agent-watcher-dedupe",
|
|
598
|
-
];
|
|
603
|
+
let patterns = ["ta_doctor_comms_orphans-", "team-agent-watcher-dedupe"];
|
|
599
604
|
patterns
|
|
600
605
|
.iter()
|
|
601
606
|
.find(|pattern| text.contains(**pattern))
|
|
@@ -626,8 +631,7 @@ fn ps_parent_map() -> BTreeMap<u32, u32> {
|
|
|
626
631
|
Command::new("ps").args(["-axo", "pid=,ppid="]),
|
|
627
632
|
"ps_parent",
|
|
628
633
|
None,
|
|
629
|
-
)
|
|
630
|
-
{
|
|
634
|
+
) {
|
|
631
635
|
Ok(output) if output.status.success() => output,
|
|
632
636
|
_ => return BTreeMap::new(),
|
|
633
637
|
};
|
|
@@ -732,4 +736,22 @@ mod tests {
|
|
|
732
736
|
Some(PathBuf::from("/tmp/My Agent"))
|
|
733
737
|
);
|
|
734
738
|
}
|
|
739
|
+
|
|
740
|
+
#[test]
|
|
741
|
+
fn process_orphan_metadata_ignores_binary_identity_rotation() {
|
|
742
|
+
let metadata = CoordinatorMetadata {
|
|
743
|
+
pid: Pid::new(123),
|
|
744
|
+
protocol_version: PROTOCOL_VERSION,
|
|
745
|
+
message_store_schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
746
|
+
binary_path: Some("/old/team-agent".to_string()),
|
|
747
|
+
binary_version: Some("0.5.17".to_string()),
|
|
748
|
+
source: crate::coordinator::MetadataSource::Boot,
|
|
749
|
+
updated_at: "2026-07-09T00:00:00+00:00".to_string(),
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
assert!(
|
|
753
|
+
!coordinator_process_metadata_mismatch(Some(&metadata), Pid::new(123)),
|
|
754
|
+
"binary identity drift is a rotation signal, not orphan cleanup proof"
|
|
755
|
+
);
|
|
756
|
+
}
|
|
735
757
|
}
|
|
@@ -157,7 +157,8 @@ pub(crate) fn start_agent_at_paths(
|
|
|
157
157
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
158
158
|
}
|
|
159
159
|
replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
|
|
160
|
-
let
|
|
160
|
+
let coordinator = start_coordinator_for_workspace(workspace, Some(&team_key))?;
|
|
161
|
+
let coordinator_started = coordinator.ok;
|
|
161
162
|
let target = format!("{}:{window}", session_name.as_str());
|
|
162
163
|
if let Some(new_binding) = refreshed_binding.as_ref() {
|
|
163
164
|
if old_binding.as_ref() != Some(new_binding) {
|
|
@@ -310,7 +311,8 @@ pub(crate) fn start_agent_at_paths(
|
|
|
310
311
|
tmux_start_mode_for_spawn(&spawn, into_existing_session),
|
|
311
312
|
)?;
|
|
312
313
|
replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
|
|
313
|
-
let
|
|
314
|
+
let coordinator = start_coordinator_for_workspace(workspace, Some(&team_key))?;
|
|
315
|
+
let coordinator_started = coordinator.ok;
|
|
314
316
|
Ok(StartAgentOutcome::Running {
|
|
315
317
|
env: AgentActionEnvelope {
|
|
316
318
|
agent_id: agent_id.clone(),
|