@team-agent/installer 0.5.21 → 0.5.23

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 CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.21"
578
+ version = "0.5.23"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.21"
12
+ version = "0.5.23"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -205,6 +205,14 @@ fn coordinator_issue_id(
205
205
  }
206
206
  crate::coordinator::CoordinatorHealthStatus::Running => {
207
207
  if !health.metadata_ok {
208
+ if health.service_available
209
+ && matches!(
210
+ health.binary_identity_relation,
211
+ crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
212
+ )
213
+ {
214
+ return None;
215
+ }
208
216
  return match health.metadata_mismatch_reason.as_deref() {
209
217
  Some(
210
218
  "binary_identity_missing"
@@ -237,6 +245,11 @@ fn coordinator_issue_value(
237
245
  "pid": health.pid.map(|pid| pid.get()),
238
246
  "metadata_ok": health.metadata_ok,
239
247
  "metadata_mismatch_reason": health.metadata_mismatch_reason.clone(),
248
+ "process_running": health.process_running,
249
+ "wire_metadata_ok": health.wire_metadata_ok,
250
+ "binary_identity_ok": health.binary_identity_ok,
251
+ "binary_identity_relation": health.binary_identity_relation.as_str(),
252
+ "service_available": health.service_available,
240
253
  "binary_path": health.current_binary_identity.binary_path.clone(),
241
254
  "binary_version": health.current_binary_identity.binary_version.clone(),
242
255
  "schema_ok": health.schema.ok,
@@ -378,6 +391,15 @@ mod tests {
378
391
  pid: Some(crate::coordinator::Pid::new(std::process::id())),
379
392
  metadata: None,
380
393
  metadata_ok: metadata_mismatch_reason.is_none(),
394
+ process_running: matches!(status, crate::coordinator::CoordinatorHealthStatus::Running),
395
+ wire_metadata_ok: metadata_mismatch_reason.is_none(),
396
+ binary_identity_ok: metadata_mismatch_reason.is_none(),
397
+ binary_identity_relation: crate::coordinator::CoordinatorBinaryIdentityRelation::Same,
398
+ service_available: matches!(
399
+ status,
400
+ crate::coordinator::CoordinatorHealthStatus::Running
401
+ ) && metadata_mismatch_reason.is_none()
402
+ && schema_ok,
381
403
  metadata_mismatch_reason: metadata_mismatch_reason.map(ToString::to_string),
382
404
  current_binary_identity: crate::coordinator::CoordinatorBinaryIdentity {
383
405
  binary_path: "/current/team-agent".to_string(),
@@ -3947,7 +3947,9 @@ pub mod diagnose_port {
3947
3947
  }
3948
3948
 
3949
3949
  fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
3950
- json!({
3950
+ let expose_binary_drift = health.service_available && !health.metadata_ok;
3951
+ let binary_identity_relation = health.binary_identity_relation.as_str();
3952
+ let mut value = json!({
3951
3953
  "ok": health.ok,
3952
3954
  "status": coordinator_status_wire(health.status),
3953
3955
  "pid": health.pid.map(|p| p.get()),
@@ -3969,7 +3971,19 @@ pub mod diagnose_port {
3969
3971
  "schema": {
3970
3972
  "message_store_schema_version": health.schema.schema_version,
3971
3973
  },
3972
- })
3974
+ });
3975
+ if expose_binary_drift {
3976
+ if let Some(obj) = value.as_object_mut() {
3977
+ obj.insert("wire_metadata_ok".to_string(), Value::Bool(true));
3978
+ obj.insert("binary_identity_ok".to_string(), Value::Bool(false));
3979
+ obj.insert(
3980
+ "binary_identity_relation".to_string(),
3981
+ Value::String(binary_identity_relation.to_string()),
3982
+ );
3983
+ obj.insert("service_available".to_string(), Value::Bool(true));
3984
+ }
3985
+ }
3986
+ value
3973
3987
  }
3974
3988
 
3975
3989
  fn coordinator_status_wire(
@@ -142,11 +142,25 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
142
142
  {
143
143
  return Ok(cmd_send_result(amb, args.json));
144
144
  }
145
+ if let Some(value) = dirty_topology_refusal_value(&selected, args.team.as_deref()) {
146
+ return Ok(cmd_send_result(value, args.json));
147
+ }
148
+ let coordinator_ensure = if target_has_known_worker(&selected.state, &target, &opts.sender) {
149
+ loud_ensure_coordinator(&selected)?
150
+ } else {
151
+ None
152
+ };
153
+ if let Some(value) =
154
+ coordinator_ensure_unavailable_value(coordinator_ensure.as_ref(), &target, &content, &opts)
155
+ {
156
+ return Ok(cmd_send_result(value, args.json));
157
+ }
145
158
  let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
146
159
  if opts.watch_result {
147
160
  outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
148
161
  }
149
162
  let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
163
+ append_loud_ensure_fields(&mut value, coordinator_ensure.as_ref());
150
164
  if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
151
165
  if let Some(obj) = value.as_object_mut() {
152
166
  obj.insert("watch".to_string(), watch_notice_json(&target, &opts));
@@ -156,6 +170,202 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
156
170
  Ok(cmd_send_result(value, args.json))
157
171
  }
158
172
 
173
+ fn dirty_topology_refusal_value(
174
+ selected: &crate::state::selector::SelectedTeam,
175
+ requested_team: Option<&str>,
176
+ ) -> Option<Value> {
177
+ let issue_ids = crate::topology::restart_dirty_topology_issue_ids(&selected.state);
178
+ if issue_ids.is_empty() {
179
+ return None;
180
+ }
181
+ let session_name = selected
182
+ .state
183
+ .get("session_name")
184
+ .and_then(Value::as_str)
185
+ .unwrap_or_default()
186
+ .to_string();
187
+ let reason = issue_ids
188
+ .first()
189
+ .cloned()
190
+ .unwrap_or_else(|| "dirty_topology".to_string());
191
+ let repair_team = requested_team
192
+ .filter(|team| !team.is_empty())
193
+ .unwrap_or(selected.team_key.as_str());
194
+ Some(json!({
195
+ "ok": false,
196
+ "status": "refused_dirty_topology",
197
+ "reason": reason,
198
+ "session_name": session_name,
199
+ "error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
200
+ "issues": issue_ids
201
+ .iter()
202
+ .map(|id| json!({"id": id}))
203
+ .collect::<Vec<_>>(),
204
+ "next_actions": [
205
+ "team-agent diagnose --json",
206
+ format!("team-agent claim-leader --team {repair_team} --confirm --json"),
207
+ format!("team-agent takeover --team {repair_team} --confirm --json")
208
+ ],
209
+ }))
210
+ }
211
+
212
+ fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
213
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
214
+ return false;
215
+ };
216
+ match target {
217
+ MessageTarget::Single(target) => agents.contains_key(target),
218
+ MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
219
+ MessageTarget::Fanout(recipients) => recipients
220
+ .iter()
221
+ .any(|recipient| agents.contains_key(recipient)),
222
+ }
223
+ }
224
+
225
+ #[derive(Debug, Clone)]
226
+ struct LoudEnsureResult {
227
+ previous_status: String,
228
+ start: crate::coordinator::StartReport,
229
+ }
230
+
231
+ fn loud_ensure_coordinator(
232
+ selected: &crate::state::selector::SelectedTeam,
233
+ ) -> Result<Option<LoudEnsureResult>, CliError> {
234
+ if in_process_unit_test() {
235
+ return Ok(None);
236
+ }
237
+ let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
238
+ let previous = crate::coordinator::coordinator_health(&workspace);
239
+ if previous.ok {
240
+ return Ok(None);
241
+ }
242
+ if previous.service_available
243
+ && matches!(
244
+ previous.binary_identity_relation,
245
+ crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
246
+ )
247
+ {
248
+ return Ok(None);
249
+ }
250
+ let previous_status = coordinator_health_status_wire(previous.status).to_string();
251
+ let start = crate::coordinator::start_coordinator_with_team(
252
+ &workspace,
253
+ Some(selected.team_key.as_str()),
254
+ )
255
+ .map_err(|error| CliError::Runtime(error.to_string()))?;
256
+ if !start.ok {
257
+ return Ok(Some(LoudEnsureResult {
258
+ previous_status,
259
+ start,
260
+ }));
261
+ }
262
+ if matches!(
263
+ start.status,
264
+ crate::coordinator::StartOutcome::Started
265
+ | crate::coordinator::StartOutcome::StartedAfterRotation
266
+ ) {
267
+ crate::event_log::EventLog::new(&selected.run_workspace)
268
+ .write(
269
+ "coordinator.ensure_restarted",
270
+ json!({
271
+ "coordinator_previous_status": previous_status,
272
+ "status": start.status,
273
+ "pid": start.pid.map(|pid| pid.get()),
274
+ "previous_pid": start.previous_pid.map(|pid| pid.get()),
275
+ "binary_path": start.binary_path,
276
+ "binary_version": start.binary_version,
277
+ "rotation_reason": start.rotation_reason,
278
+ }),
279
+ )
280
+ .map_err(|error| CliError::Runtime(error.to_string()))?;
281
+ return Ok(Some(LoudEnsureResult {
282
+ previous_status,
283
+ start,
284
+ }));
285
+ }
286
+ Ok(None)
287
+ }
288
+
289
+ #[cfg(test)]
290
+ fn in_process_unit_test() -> bool {
291
+ true
292
+ }
293
+
294
+ #[cfg(not(test))]
295
+ fn in_process_unit_test() -> bool {
296
+ false
297
+ }
298
+
299
+ fn coordinator_ensure_unavailable_value(
300
+ ensure: Option<&LoudEnsureResult>,
301
+ target: &MessageTarget,
302
+ content: &str,
303
+ opts: &SendOptions,
304
+ ) -> Option<Value> {
305
+ let ensure = ensure?;
306
+ if ensure.start.ok {
307
+ return None;
308
+ }
309
+ let warning = format!(
310
+ "coordinator is not running; message was not queued for {}. Run `team-agent diagnose` or restart the team before sending again.",
311
+ first_target(target)
312
+ );
313
+ let mut value = json!({
314
+ "ok": false,
315
+ "status": "degraded",
316
+ "delivery_status": "degraded",
317
+ "delivered": false,
318
+ "target": target_json(target),
319
+ "agent_id": first_target(target),
320
+ "content_length_bytes": content.len(),
321
+ "sender": opts.sender,
322
+ "message_id": Value::Null,
323
+ "message_status": "degraded",
324
+ "verification": warning,
325
+ "stage": Value::Null,
326
+ "reason": "coordinator_unavailable",
327
+ "channel": "coordinator_unavailable",
328
+ });
329
+ append_loud_ensure_fields(&mut value, Some(ensure));
330
+ Some(value)
331
+ }
332
+
333
+ fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
334
+ let Some(ensure) = ensure else {
335
+ return;
336
+ };
337
+ if !ensure.start.ok {
338
+ return;
339
+ }
340
+ if let Some(obj) = value.as_object_mut() {
341
+ obj.insert("coordinator_auto_restarted".to_string(), json!(true));
342
+ obj.insert(
343
+ "coordinator_previous_status".to_string(),
344
+ json!(ensure.previous_status),
345
+ );
346
+ obj.insert(
347
+ "coordinator".to_string(),
348
+ coordinator_start_json(&ensure.start),
349
+ );
350
+ }
351
+ }
352
+
353
+ fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
354
+ let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
355
+ crate::lifecycle::coordinator_start_summary_value(&summary)
356
+ }
357
+
358
+ fn coordinator_health_status_wire(
359
+ status: crate::coordinator::CoordinatorHealthStatus,
360
+ ) -> &'static str {
361
+ match status {
362
+ crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
363
+ crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
364
+ crate::coordinator::CoordinatorHealthStatus::Running => "running",
365
+ crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
366
+ }
367
+ }
368
+
159
369
  /// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
160
370
  /// membership check. Constructs `Target::Pane`, renders the message with
161
371
  /// the standard protocol block (Team Agent message from sender + token),
@@ -1143,7 +1143,9 @@ use rusqlite::params;
1143
1143
  }
1144
1144
 
1145
1145
  fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
1146
- json!({
1146
+ let expose_binary_drift = health.service_available && !health.metadata_ok;
1147
+ let binary_identity_relation = health.binary_identity_relation.as_str();
1148
+ let mut value = json!({
1147
1149
  "ok": health.ok,
1148
1150
  "status": coordinator_status_wire(health.status),
1149
1151
  "pid": health.pid.map(|p| p.get()),
@@ -1165,7 +1167,19 @@ use rusqlite::params;
1165
1167
  "schema": {
1166
1168
  "message_store_schema_version": health.schema.schema_version,
1167
1169
  },
1168
- })
1170
+ });
1171
+ if expose_binary_drift {
1172
+ if let Some(obj) = value.as_object_mut() {
1173
+ obj.insert("wire_metadata_ok".to_string(), Value::Bool(true));
1174
+ obj.insert("binary_identity_ok".to_string(), Value::Bool(false));
1175
+ obj.insert(
1176
+ "binary_identity_relation".to_string(),
1177
+ Value::String(binary_identity_relation.to_string()),
1178
+ );
1179
+ obj.insert("service_available".to_string(), Value::Bool(true));
1180
+ }
1181
+ }
1182
+ value
1169
1183
  }
1170
1184
 
1171
1185
  fn coordinator_status_wire(status: crate::coordinator::CoordinatorHealthStatus) -> &'static str {
@@ -13,10 +13,10 @@ use thiserror::Error;
13
13
  use crate::message_store::MessageStore;
14
14
 
15
15
  use super::types::{
16
- CoordinatorBinaryIdentity, CoordinatorHealthStatus, CoordinatorMetadata,
17
- CoordinatorMetadataMismatchReason, HealthReport, MetadataSource, Pid, SchemaError,
18
- SchemaHealth, StartError, StartOutcome, StartReport, StopError, StopOutcome, StopReport,
19
- WatchCursor, WorkspacePath, PROTOCOL_VERSION, ROTATION_MARKER,
16
+ CoordinatorBinaryIdentity, CoordinatorBinaryIdentityRelation, CoordinatorHealthStatus,
17
+ CoordinatorMetadata, CoordinatorMetadataMismatchReason, HealthReport, MetadataSource, Pid,
18
+ SchemaError, SchemaHealth, StartError, StartOutcome, StartReport, StopError, StopOutcome,
19
+ StopReport, WatchCursor, WorkspacePath, PROTOCOL_VERSION, ROTATION_MARKER,
20
20
  };
21
21
 
22
22
  // ===========================================================================
@@ -40,21 +40,30 @@ pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
40
40
  None => CoordinatorHealthStatus::Missing,
41
41
  };
42
42
  let metadata = read_coordinator_metadata(workspace);
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
- ))
43
+ let wire_metadata_mismatch = pid
44
+ .map(|p| coordinator_wire_metadata_mismatch_reason(metadata.as_ref(), p))
49
45
  .unwrap_or(Some(CoordinatorMetadataMismatchReason::MetadataMissing));
50
- let metadata_ok = metadata_mismatch.is_none();
51
- let running = matches!(status, CoordinatorHealthStatus::Running);
46
+ let binary_identity_mismatch =
47
+ coordinator_binary_identity_mismatch_reason(metadata.as_ref(), &current_binary_identity);
48
+ let wire_metadata_ok = wire_metadata_mismatch.is_none();
49
+ let binary_identity_ok = binary_identity_mismatch.is_none();
50
+ let metadata_mismatch = wire_metadata_mismatch.or(binary_identity_mismatch);
51
+ let metadata_ok = wire_metadata_ok && binary_identity_ok;
52
+ let process_running = matches!(status, CoordinatorHealthStatus::Running);
53
+ let binary_identity_relation =
54
+ coordinator_binary_identity_relation(metadata.as_ref(), &current_binary_identity);
55
+ let service_available = process_running && wire_metadata_ok && schema.ok;
52
56
  HealthReport {
53
- ok: running && metadata_ok && schema.ok,
57
+ ok: process_running && metadata_ok && schema.ok,
54
58
  status,
55
59
  pid,
56
60
  metadata,
57
61
  metadata_ok,
62
+ process_running,
63
+ wire_metadata_ok,
64
+ binary_identity_ok,
65
+ binary_identity_relation,
66
+ service_available,
58
67
  metadata_mismatch_reason: metadata_mismatch.map(|reason| reason.as_str().to_string()),
59
68
  current_binary_identity,
60
69
  schema,
@@ -109,6 +118,62 @@ pub fn start_coordinator_with_team(
109
118
  action: health.schema.action,
110
119
  });
111
120
  }
121
+ if matches!(health.status, CoordinatorHealthStatus::Running) && !health.wire_metadata_ok {
122
+ return Ok(StartReport {
123
+ ok: false,
124
+ pid: health.pid,
125
+ status: StartOutcome::RestartIncompatibleStopFailed,
126
+ previous_pid: health.pid,
127
+ binary_path: Some(identity.binary_path),
128
+ binary_version: Some(identity.binary_version),
129
+ rotation_reason: health.metadata_mismatch_reason,
130
+ log: None,
131
+ schema_error: None,
132
+ action: Some(
133
+ "refusing to rotate a coordinator with incompatible protocol or schema metadata"
134
+ .to_string(),
135
+ ),
136
+ });
137
+ }
138
+ if matches!(health.status, CoordinatorHealthStatus::Running)
139
+ && health.wire_metadata_ok
140
+ && !health.binary_identity_ok
141
+ && matches!(
142
+ health.binary_identity_relation,
143
+ CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
144
+ )
145
+ {
146
+ crate::event_log::EventLog::new(workspace.as_path()).write(
147
+ "coordinator.newer_daemon_preserved",
148
+ serde_json::json!({
149
+ "pid": health.pid.map(|pid| pid.get()),
150
+ "binary_identity_relation": health.binary_identity_relation.as_str(),
151
+ "reason": "daemon_newer_than_caller",
152
+ "daemon_binary_path": health
153
+ .metadata
154
+ .as_ref()
155
+ .and_then(|metadata| metadata.binary_path.clone()),
156
+ "daemon_binary_version": health
157
+ .metadata
158
+ .as_ref()
159
+ .and_then(|metadata| metadata.binary_version.clone()),
160
+ "caller_binary_path": identity.binary_path.clone(),
161
+ "caller_binary_version": identity.binary_version.clone(),
162
+ }),
163
+ )?;
164
+ return Ok(StartReport {
165
+ ok: true,
166
+ pid: health.pid,
167
+ status: StartOutcome::AlreadyRunning,
168
+ previous_pid: None,
169
+ binary_path: Some(identity.binary_path),
170
+ binary_version: Some(identity.binary_version),
171
+ rotation_reason: Some("daemon_newer_than_caller".to_string()),
172
+ log: Some(coordinator_log_path(workspace)),
173
+ schema_error: None,
174
+ action: None,
175
+ });
176
+ }
112
177
  let rotation_reason = if matches!(health.status, CoordinatorHealthStatus::Running)
113
178
  && !health.metadata_ok
114
179
  {
@@ -580,6 +645,13 @@ pub fn read_coordinator_metadata(workspace: &WorkspacePath) -> Option<Coordinato
580
645
  }
581
646
 
582
647
  pub fn current_coordinator_binary_identity() -> CoordinatorBinaryIdentity {
648
+ if let Ok(raw) = std::env::var("TEAM_AGENT_TEST_CALLER_BINARY_IDENTITY") {
649
+ if let Ok(identity) = serde_json::from_str::<CoordinatorBinaryIdentity>(&raw) {
650
+ if !identity.binary_path.is_empty() && !identity.binary_version.is_empty() {
651
+ return identity;
652
+ }
653
+ }
654
+ }
583
655
  let binary_path = std::env::current_exe()
584
656
  .map(|path| path.canonicalize().unwrap_or(path))
585
657
  .map(|path| path.to_string_lossy().into_owned())
@@ -608,6 +680,14 @@ fn coordinator_metadata_mismatch_reason_with_identity(
608
680
  metadata: Option<&CoordinatorMetadata>,
609
681
  pid: Pid,
610
682
  identity: &CoordinatorBinaryIdentity,
683
+ ) -> Option<CoordinatorMetadataMismatchReason> {
684
+ coordinator_wire_metadata_mismatch_reason(metadata, pid)
685
+ .or_else(|| coordinator_binary_identity_mismatch_reason(metadata, identity))
686
+ }
687
+
688
+ fn coordinator_wire_metadata_mismatch_reason(
689
+ metadata: Option<&CoordinatorMetadata>,
690
+ pid: Pid,
611
691
  ) -> Option<CoordinatorMetadataMismatchReason> {
612
692
  let Some(metadata) = metadata else {
613
693
  return Some(CoordinatorMetadataMismatchReason::MetadataMissing);
@@ -621,6 +701,16 @@ fn coordinator_metadata_mismatch_reason_with_identity(
621
701
  if metadata.message_store_schema_version != crate::db::schema::SCHEMA_VERSION {
622
702
  return Some(CoordinatorMetadataMismatchReason::MessageStoreSchemaVersionMismatch);
623
703
  }
704
+ None
705
+ }
706
+
707
+ fn coordinator_binary_identity_mismatch_reason(
708
+ metadata: Option<&CoordinatorMetadata>,
709
+ identity: &CoordinatorBinaryIdentity,
710
+ ) -> Option<CoordinatorMetadataMismatchReason> {
711
+ let Some(metadata) = metadata else {
712
+ return Some(CoordinatorMetadataMismatchReason::MetadataMissing);
713
+ };
624
714
  let Some(binary_version) = metadata.binary_version.as_deref().filter(|value| !value.is_empty())
625
715
  else {
626
716
  return Some(CoordinatorMetadataMismatchReason::BinaryIdentityMissing);
@@ -632,12 +722,98 @@ fn coordinator_metadata_mismatch_reason_with_identity(
632
722
  if binary_version != identity.binary_version {
633
723
  return Some(CoordinatorMetadataMismatchReason::BinaryVersionMismatch);
634
724
  }
635
- if binary_path != identity.binary_path {
725
+ if !binary_path_matches_current_identity(binary_path, &identity.binary_path) {
636
726
  return Some(CoordinatorMetadataMismatchReason::BinaryPathMismatch);
637
727
  }
638
728
  None
639
729
  }
640
730
 
731
+ fn coordinator_binary_identity_relation(
732
+ metadata: Option<&CoordinatorMetadata>,
733
+ identity: &CoordinatorBinaryIdentity,
734
+ ) -> CoordinatorBinaryIdentityRelation {
735
+ let Some(metadata) = metadata else {
736
+ return CoordinatorBinaryIdentityRelation::Unknown;
737
+ };
738
+ let Some(daemon_version) = metadata
739
+ .binary_version
740
+ .as_deref()
741
+ .filter(|value| !value.is_empty())
742
+ else {
743
+ return CoordinatorBinaryIdentityRelation::Unknown;
744
+ };
745
+ match compare_version_strings(daemon_version, &identity.binary_version) {
746
+ Some(std::cmp::Ordering::Greater) => {
747
+ return CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller;
748
+ }
749
+ Some(std::cmp::Ordering::Less) => {
750
+ return CoordinatorBinaryIdentityRelation::CallerNewerThanDaemon;
751
+ }
752
+ Some(std::cmp::Ordering::Equal) => {}
753
+ None => return CoordinatorBinaryIdentityRelation::Unknown,
754
+ }
755
+ let Some(daemon_path) = metadata
756
+ .binary_path
757
+ .as_deref()
758
+ .filter(|value| !value.is_empty())
759
+ else {
760
+ return CoordinatorBinaryIdentityRelation::Unknown;
761
+ };
762
+ if binary_path_matches_current_identity(daemon_path, &identity.binary_path) {
763
+ CoordinatorBinaryIdentityRelation::Same
764
+ } else {
765
+ CoordinatorBinaryIdentityRelation::SameVersionPathMismatch
766
+ }
767
+ }
768
+
769
+ fn compare_version_strings(left: &str, right: &str) -> Option<std::cmp::Ordering> {
770
+ let left = parse_numeric_version(left)?;
771
+ let right = parse_numeric_version(right)?;
772
+ Some(left.cmp(&right))
773
+ }
774
+
775
+ fn parse_numeric_version(value: &str) -> Option<Vec<u64>> {
776
+ if value.is_empty() {
777
+ return None;
778
+ }
779
+ value
780
+ .split('.')
781
+ .map(|part| part.parse::<u64>().ok())
782
+ .collect()
783
+ }
784
+
785
+ fn binary_path_matches_current_identity(metadata_path: &str, identity_path: &str) -> bool {
786
+ if metadata_path == identity_path {
787
+ return true;
788
+ }
789
+ test_harness_binary_path_matches(metadata_path)
790
+ }
791
+
792
+ /// Test harness escape hatch for integration tests whose process identity is the
793
+ /// test binary while fixture metadata intentionally points at the built CLI.
794
+ /// Production must not infer this from path shape; callers must set the
795
+ /// `TEAM_AGENT_TEST_HARNESS_BINARY_PATH_MATCH` env explicitly to either the
796
+ /// expected binary path or the target directory containing `team-agent`.
797
+ fn test_harness_binary_path_matches(metadata_path: &str) -> bool {
798
+ let Ok(path) = std::env::var("TEAM_AGENT_TEST_HARNESS_BINARY_PATH_MATCH") else {
799
+ return false;
800
+ };
801
+ let path = PathBuf::from(path);
802
+ path_matches(metadata_path, &path)
803
+ || path_matches(metadata_path, &path.join("team-agent"))
804
+ || path
805
+ .parent()
806
+ .is_some_and(|parent| path_matches(metadata_path, &parent.join("team-agent")))
807
+ }
808
+
809
+ fn path_matches(metadata_path: &str, path: &Path) -> bool {
810
+ path.to_string_lossy() == metadata_path
811
+ || path
812
+ .canonicalize()
813
+ .ok()
814
+ .is_some_and(|path| path.to_string_lossy() == metadata_path)
815
+ }
816
+
641
817
  /// `write_coordinator_metadata`(`metadata.py:46-61`)。写 `coordinator.json`(pretty indent=2),
642
818
  /// `updated_at = now(utc).isoformat()`。
643
819
  pub fn write_coordinator_metadata(
@@ -189,6 +189,29 @@ pub struct CoordinatorBinaryIdentity {
189
189
  pub binary_version: String,
190
190
  }
191
191
 
192
+ /// Caller-relative daemon binary identity relation.
193
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
194
+ #[serde(rename_all = "snake_case")]
195
+ pub enum CoordinatorBinaryIdentityRelation {
196
+ Same,
197
+ CallerNewerThanDaemon,
198
+ DaemonNewerThanCaller,
199
+ SameVersionPathMismatch,
200
+ Unknown,
201
+ }
202
+
203
+ impl CoordinatorBinaryIdentityRelation {
204
+ pub fn as_str(self) -> &'static str {
205
+ match self {
206
+ Self::Same => "same",
207
+ Self::CallerNewerThanDaemon => "caller_newer_than_daemon",
208
+ Self::DaemonNewerThanCaller => "daemon_newer_than_caller",
209
+ Self::SameVersionPathMismatch => "same_version_path_mismatch",
210
+ Self::Unknown => "unknown",
211
+ }
212
+ }
213
+ }
214
+
192
215
  /// Stable machine-readable reason for coordinator metadata rejection.
193
216
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
194
217
  #[serde(rename_all = "snake_case")]
@@ -285,6 +308,13 @@ pub struct HealthReport {
285
308
  pub metadata: Option<CoordinatorMetadata>,
286
309
  /// 三元全等(`coordinator_metadata_ok`,`metadata.py:37-43`)。
287
310
  pub metadata_ok: bool,
311
+ pub process_running: bool,
312
+ /// pid/protocol/schema match; excludes daemon binary identity.
313
+ pub wire_metadata_ok: bool,
314
+ pub binary_identity_ok: bool,
315
+ pub binary_identity_relation: CoordinatorBinaryIdentityRelation,
316
+ /// A live daemon that can process this workspace's queue.
317
+ pub service_available: bool,
288
318
  pub metadata_mismatch_reason: Option<String>,
289
319
  pub current_binary_identity: CoordinatorBinaryIdentity,
290
320
  pub schema: SchemaHealth,
@@ -50,6 +50,7 @@ pub(crate) fn reset_agent(
50
50
  })),
51
51
  });
52
52
  }
53
+ emit_newer_daemon_preserved_if_present(workspace)?;
53
54
  let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, true)?;
54
55
  match crate::lifecycle::reset_agent(
55
56
  &lifecycle_workspace,
@@ -97,6 +98,7 @@ pub(crate) fn fork_agent(
97
98
  as_agent_id: &str,
98
99
  label: Option<&str>,
99
100
  ) -> ToolResult {
101
+ emit_newer_daemon_preserved_if_present(workspace)?;
100
102
  let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, false)?;
101
103
  // operations.py:315 — the label becomes the forked agent's role.
102
104
  let report = crate::lifecycle::launch::fork_agent(
@@ -128,6 +130,41 @@ fn reset_refusal_reason(reason: ResetRefusal) -> Value {
128
130
  }
129
131
  }
130
132
 
133
+ fn emit_newer_daemon_preserved_if_present(workspace: &Path) -> Result<(), super::super::ToolError> {
134
+ let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
135
+ let health = crate::coordinator::coordinator_health(&workspace_path);
136
+ if !(health.service_available
137
+ && !health.binary_identity_ok
138
+ && matches!(
139
+ health.binary_identity_relation,
140
+ crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
141
+ ))
142
+ {
143
+ return Ok(());
144
+ }
145
+ crate::event_log::EventLog::new(workspace)
146
+ .write(
147
+ "coordinator.newer_daemon_preserved",
148
+ serde_json::json!({
149
+ "pid": health.pid.map(|pid| pid.get()),
150
+ "binary_identity_relation": health.binary_identity_relation.as_str(),
151
+ "reason": "daemon_newer_than_caller",
152
+ "daemon_binary_path": health
153
+ .metadata
154
+ .as_ref()
155
+ .and_then(|metadata| metadata.binary_path.clone()),
156
+ "daemon_binary_version": health
157
+ .metadata
158
+ .as_ref()
159
+ .and_then(|metadata| metadata.binary_version.clone()),
160
+ "caller_binary_path": health.current_binary_identity.binary_path,
161
+ "caller_binary_version": health.current_binary_identity.binary_version,
162
+ }),
163
+ )
164
+ .map(|_| ())
165
+ .map_err(tool_runtime_error)
166
+ }
167
+
131
168
  fn lifecycle_workspace(
132
169
  workspace: &Path,
133
170
  owner_team: Option<&TeamKey>,
@@ -319,6 +319,30 @@ fn coordinator_unavailable_outcome(
319
319
  if health.ok || matches!(health.status, CoordinatorHealthStatus::Missing) {
320
320
  return Ok(None);
321
321
  }
322
+ if health.service_available {
323
+ event_log.write(
324
+ "send.coordinator_binary_identity_drift_ignored",
325
+ serde_json::json!({
326
+ "recipient": recipient,
327
+ "sender": opts.sender,
328
+ "coordinator_status": health.status,
329
+ "coordinator_pid": health.pid.map(|pid| pid.get()),
330
+ "message_queued": true,
331
+ "binary_identity_relation": health.binary_identity_relation.as_str(),
332
+ "daemon_binary_path": health
333
+ .metadata
334
+ .as_ref()
335
+ .and_then(|metadata| metadata.binary_path.clone()),
336
+ "daemon_binary_version": health
337
+ .metadata
338
+ .as_ref()
339
+ .and_then(|metadata| metadata.binary_version.clone()),
340
+ "caller_binary_path": health.current_binary_identity.binary_path,
341
+ "caller_binary_version": health.current_binary_identity.binary_version,
342
+ }),
343
+ )?;
344
+ return Ok(None);
345
+ }
322
346
  let warning = format!(
323
347
  "coordinator is not running; message was not queued for {recipient}. Run `team-agent diagnose` or restart the team before sending again."
324
348
  );
@@ -329,6 +353,7 @@ fn coordinator_unavailable_outcome(
329
353
  "sender": opts.sender,
330
354
  "coordinator_status": health.status,
331
355
  "coordinator_pid": health.pid.map(|pid| pid.get()),
356
+ "metadata_mismatch_reason": health.metadata_mismatch_reason,
332
357
  "message_queued": false,
333
358
  "warning": warning,
334
359
  "coordinator_log": crate::coordinator::coordinator_log_path(&coordinator_workspace)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.21",
24
- "@team-agent/cli-darwin-x64": "0.5.21",
25
- "@team-agent/cli-linux-x64": "0.5.21"
23
+ "@team-agent/cli-darwin-arm64": "0.5.23",
24
+ "@team-agent/cli-darwin-x64": "0.5.23",
25
+ "@team-agent/cli-linux-x64": "0.5.23"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",