@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.
@@ -13,7 +13,8 @@ use thiserror::Error;
13
13
  use crate::message_store::MessageStore;
14
14
 
15
15
  use super::types::{
16
- CoordinatorHealthStatus, CoordinatorMetadata, HealthReport, MetadataSource, Pid, SchemaError,
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 metadata_ok = pid.is_some_and(|p| coordinator_metadata_ok(metadata.as_ref(), p));
43
+ let metadata_mismatch = pid
44
+ .map(|p| coordinator_metadata_mismatch_reason_with_identity(
45
+ metadata.as_ref(),
46
+ p,
47
+ &current_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
- if health.pid.is_some() && !health.metadata_ok && health.metadata.is_some() {
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: StartOutcome::Started,
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
- /// `coordinator_metadata_ok`(`metadata.py:37-43`):三元全等
503
- /// `meta.pid == pid ∧ meta.protocol_version == PROTOCOL_VERSION ∧
504
- /// meta.message_store_schema_version == SCHEMA_VERSION`。任一不符 → false(不静默继续旧 schema)
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.is_some_and(|m| {
507
- m.pid == pid
508
- && m.protocol_version == PROTOCOL_VERSION
509
- && m.message_store_schema_version == crate::db::schema::SCHEMA_VERSION
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(workspace: &std::path::Path, raw_state: &Value) -> Option<Value> {
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 has_team_entry = raw_state
116
- .get("teams")
117
- .and_then(Value::as_object)
118
- .map(|teams| teams.contains_key(active))
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(self.workspace.as_path(), &raw_state)
228
- .unwrap_or(raw_state);
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
- let schema = self.schema_health();
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
- let health = self
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::{OrphanReason, Pid, WorkspacePath};
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 metadata.is_some() && !coordinator_metadata_ok(metadata.as_ref(), pid) {
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 coordinator_started = start_coordinator_for_workspace(workspace)?;
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 coordinator_started = start_coordinator_for_workspace(workspace)?;
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(),