@team-agent/installer 0.5.22 → 0.5.24

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.22"
578
+ version = "0.5.24"
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.22"
12
+ version = "0.5.24"
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(
@@ -239,6 +239,14 @@ fn loud_ensure_coordinator(
239
239
  if previous.ok {
240
240
  return Ok(None);
241
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
+ }
242
250
  let previous_status = coordinator_health_status_wire(previous.status).to_string();
243
251
  let start = crate::coordinator::start_coordinator_with_team(
244
252
  &workspace,
@@ -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);
@@ -638,6 +728,60 @@ fn coordinator_metadata_mismatch_reason_with_identity(
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
+
641
785
  fn binary_path_matches_current_identity(metadata_path: &str, identity_path: &str) -> bool {
642
786
  if metadata_path == identity_path {
643
787
  return true;
@@ -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,
@@ -1615,33 +1615,14 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
1615
1615
  .and_then(|v| serde_json::from_value(v).ok())
1616
1616
  }
1617
1617
 
1618
- /// `_write_lease_dual_state`(card §85 C17;`__init__.py:588`)。同一锁内写 workspace state.json
1619
- /// + team/<session> snapshot,两份永不分叉。**CROSS-LANE**:snapshot 写经 step 13 restart。
1618
+ /// `_write_lease_dual_state` Foundation-0 F0-2: the historical dual
1619
+ /// write to the legacy per-session snapshot has been retired
1620
+ /// (`.team/artifacts/foundation-0-slice-design.md` §§4-5). This helper
1621
+ /// now persists ONLY the canonical root state; retaining the public
1622
+ /// name for 0.5.x call-site stability. The B0 legacy snapshot is
1623
+ /// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
1620
1624
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
1621
1625
  crate::state::persist::save_runtime_state(workspace, state)?;
1622
- if let Some(session_name) = state.get("session_name").and_then(Value::as_str) {
1623
- write_team_snapshot_atomic(workspace, session_name, state)?;
1624
- }
1625
- Ok(())
1626
- }
1627
-
1628
- fn write_team_snapshot_atomic(
1629
- workspace: &Path,
1630
- session_name: &str,
1631
- state: &Value,
1632
- ) -> Result<(), LeaderError> {
1633
- let snap_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1634
- let parent = snap_path
1635
- .parent()
1636
- .ok_or_else(|| LeaderError::Validation("team snapshot path has no parent".to_string()))?;
1637
- std::fs::create_dir_all(parent)?;
1638
- let tmp = parent.join(format!("state.json.tmp-{}", std::process::id()));
1639
- let data = serde_json::to_vec_pretty(state)?;
1640
- std::fs::write(&tmp, data)?;
1641
- if let Err(error) = std::fs::rename(&tmp, &snap_path) {
1642
- let _ = std::fs::remove_file(&tmp);
1643
- return Err(error.into());
1644
- }
1645
1626
  Ok(())
1646
1627
  }
1647
1628
 
@@ -1769,17 +1750,22 @@ fn compact_team_state_preserving_claim_fields(state: &Value, target_key: &str) -
1769
1750
  entry
1770
1751
  }
1771
1752
 
1772
- /// `_detect_dual_state_divergence`(card §85 C18;`__init__.py:556`)。workspace-level
1773
- /// team-level snapshot owner_uuid/receiver_pane_id/owner_epoch 上是否分叉 `Some(详情)`。
1774
- /// **CROSS-LANE**:snapshot 读经 step 13 restart。
1775
- pub fn detect_dual_state_divergence(
1753
+ /// Foundation-0 F0-2: reader for legacy per-session snapshot vs
1754
+ /// canonical root state. Diagnostic-only after the dual-write retirement
1755
+ /// (`.team/artifacts/foundation-0-slice-design.md` §§4-5); product
1756
+ /// authority code never consults this — it exists so `status`/`diagnose`
1757
+ /// can surface `legacy_snapshot_stale` when the on-disk sidecar has
1758
+ /// drifted. Every touch of the legacy path constants below is marked
1759
+ /// `B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ` so the RED3 grep guard admits
1760
+ /// them as documented exceptions.
1761
+ pub fn detect_dual_state_divergence( // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only entry point; no product save/route consumer.
1776
1762
  workspace: &Path,
1777
1763
  state: &Value,
1778
1764
  ) -> Result<Option<Value>, LeaderError> {
1779
1765
  let Some(session_name) = state.get("session_name").and_then(Value::as_str) else {
1780
1766
  return Ok(None);
1781
1767
  };
1782
- let snap_path = readable_team_snapshot_path(workspace, session_name);
1768
+ let snap_path = readable_team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic legacy-shape path lookup.
1783
1769
  if !snap_path.exists() {
1784
1770
  return Ok(None);
1785
1771
  }
@@ -1810,16 +1796,19 @@ pub fn detect_dual_state_divergence(
1810
1796
  "team_receiver_pane": team_receiver_pane,
1811
1797
  "workspace_owner_epoch": workspace_epoch,
1812
1798
  "team_owner_epoch": team_epoch,
1799
+ "_legacy_snapshot_stale": true,
1813
1800
  })))
1814
1801
  }
1815
1802
 
1816
- fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf {
1817
- let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1803
+ fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf { // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only path resolver.
1804
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: reuses helpers safe legacy path.
1818
1805
  if safe_path.exists() {
1819
1806
  return safe_path;
1820
1807
  }
1808
+ // Raw legacy `runtime/teams` fallback for pre-safe-shape snapshots. // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ
1809
+ let legacy_dir = "teams"; // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ
1821
1810
  crate::model::paths::runtime_dir(workspace)
1822
- .join("teams")
1811
+ .join(legacy_dir)
1823
1812
  .join(session_name)
1824
1813
  .join("state.json")
1825
1814
  }
@@ -135,7 +135,12 @@ fn try_readopt_writes_owner_receiver_dual_state_and_events() {
135
135
  assert_eq!(owner_view["claimed_via"], json!("attach-leader"));
136
136
  assert_eq!(owner_view["owner_epoch"], json!(4));
137
137
  assert_eq!(receiver_view["discovery"], json!("attach_readopt"));
138
- assert!(crate::model::paths::runtime_dir(&ws)
138
+ // Foundation-0 F0-2: the try_readopt success path no longer side-
139
+ // effects a legacy per-session snapshot; canonical teams.<key> is
140
+ // the sole authority. The legacy sidecar stays absent unless a
141
+ // diagnostic writer explicitly created it
142
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
143
+ assert!(!crate::model::paths::runtime_dir(&ws)
139
144
  .join("teams")
140
145
  .join("sess")
141
146
  .join("state.json")
@@ -112,7 +112,15 @@ use super::*;
112
112
  // 强化:带 session_name 时必须落 BOTH —— workspace state.json + team/<session> snapshot,
113
113
  // 两份 team_owner.pane_id / owner_epoch 必须一致(永不分叉)。空 body Ok(()) 会被这里抓。
114
114
  #[test]
115
- fn write_lease_dual_state_persists_both_locations_without_divergence() {
115
+ // Foundation-0 F0-2 conversion of the C17 dual-write invariant test:
116
+ // legacy per-session snapshot dual-write has been retired
117
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). The lease
118
+ // helper now persists ONLY the canonical root state; a legacy
119
+ // snapshot is diagnostic-only and never re-appears as a side effect
120
+ // of `write_lease_dual_state`. This test enforces exactly that:
121
+ // canonical root gets the owner tuple, and the legacy per-session
122
+ // path stays absent unless a diagnostic path explicitly writes it.
123
+ fn write_lease_dual_state_persists_canonical_root_only_no_legacy_side_effect() {
116
124
  let ws = std::env::temp_dir().join(format!("ta_rs_dual_{}", std::process::id()));
117
125
  std::fs::create_dir_all(&ws).unwrap();
118
126
  let state = serde_json::json!({
@@ -121,32 +129,33 @@ use super::*;
121
129
  "leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
122
130
  });
123
131
  write_lease_dual_state(&ws, &state).unwrap();
124
- // (1) workspace state.json(<ws>/.team/runtime/state.json)被写,team_owner.pane_id==%1。
132
+ // Canonical root state.json IS written; F0-2 keeps this as the
133
+ // single product authority.
125
134
  let ws_path = crate::state::persist::runtime_state_path(&ws);
126
135
  let ws_state: serde_json::Value =
127
136
  serde_json::from_str(&std::fs::read_to_string(&ws_path).expect("workspace state.json 必须存在")).unwrap();
128
137
  assert_eq!(ws_state["team_owner"]["pane_id"], serde_json::json!("%1"));
129
138
  assert_eq!(ws_state["team_owner"]["owner_epoch"], serde_json::json!(2));
130
- // (2) team-level snapshot(<ws>/.team/runtime/teams/<session>/state.json)被写。
131
- let snap_path = crate::model::paths::runtime_dir(&ws)
132
- .join("teams")
133
- .join("team-sess")
134
- .join("state.json");
135
- let snap_state: serde_json::Value =
136
- serde_json::from_str(&std::fs::read_to_string(&snap_path).expect("team snapshot state.json 必须存在")).unwrap();
137
- // (3) 两份永不分叉:owner pane_id / owner_epoch 必须相等(C17 核心不变量)。
138
- assert_eq!(
139
- ws_state["team_owner"]["pane_id"], snap_state["team_owner"]["pane_id"],
140
- "workspace 与 team snapshot 的 owner pane 不得分叉"
141
- );
142
- assert_eq!(
143
- ws_state["team_owner"]["owner_epoch"], snap_state["team_owner"]["owner_epoch"],
144
- "workspace 与 team snapshot 的 owner_epoch 不得分叉"
139
+ // Legacy per-session snapshot MUST NOT be a side effect of the
140
+ // lease save path any more — this is the RED2 authority-write
141
+ // guard shape at the unit-test layer.
142
+ let legacy_snap = crate::lifecycle::helpers::team_snapshot_path(&ws, "team-sess");
143
+ assert!(
144
+ !legacy_snap.exists(),
145
+ "F0-2: write_lease_dual_state must not write the legacy per-session snapshot; found {}",
146
+ legacy_snap.display(),
145
147
  );
146
148
  }
147
149
 
148
150
  #[test]
149
151
  fn lease_divergence_reads_restart_snapshot_for_special_session_names() {
152
+ // Foundation-0 F0-2 conversion: `write_lease_dual_state` no longer
153
+ // side-effects a legacy snapshot; the divergence detector is a
154
+ // diagnostic reader that fires only when a diagnostic writer
155
+ // (e.g. `lifecycle::save_team_runtime_snapshot`) has produced
156
+ // the sidecar. Sequence exercised here: canonical root save →
157
+ // diagnostic snapshot save with a different pane → divergence
158
+ // reader observes the drift.
150
159
  let ws = std::env::temp_dir().join(format!("ta_rs_dual_special_{}", std::process::id()));
151
160
  std::fs::create_dir_all(&ws).unwrap();
152
161
  let state = serde_json::json!({
@@ -160,17 +169,21 @@ use super::*;
160
169
  .join("teams")
161
170
  .join("team:proj-中文")
162
171
  .join("state.json");
172
+ // F0-2: canonical save must not create either the restart-safe
173
+ // or the raw legacy snapshot as a side effect.
163
174
  assert!(
164
- safe_path.exists(),
165
- "lease snapshot must use the restart-safe path: {}",
175
+ !safe_path.exists(),
176
+ "F0-2: canonical write_lease_dual_state must not create the restart-safe legacy snapshot: {}",
166
177
  safe_path.display()
167
178
  );
168
179
  assert!(
169
180
  !raw_path.exists(),
170
- "new lease writes must not create the legacy raw session path: {}",
181
+ "F0-2: canonical write_lease_dual_state must not create the raw legacy snapshot: {}",
171
182
  raw_path.display()
172
183
  );
173
184
 
185
+ // Diagnostic write path with a divergent pane so we can exercise
186
+ // the divergence reader below.
174
187
  let restart_state = serde_json::json!({
175
188
  "session_name": "team:proj-中文",
176
189
  "team_owner": {"pane_id": "%9", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
@@ -180,13 +193,18 @@ use super::*;
180
193
  assert_eq!(restart_path, safe_path);
181
194
  assert!(
182
195
  restart_path.exists(),
183
- "restart snapshot must be written to a real file: {}",
196
+ "diagnostic snapshot must be written to a real file: {}",
184
197
  restart_path.display()
185
198
  );
186
199
 
200
+ // F0-2 RED1 diagnostic marker is present on the on-disk file.
201
+ let snap_state: serde_json::Value =
202
+ serde_json::from_str(&std::fs::read_to_string(&restart_path).unwrap()).unwrap();
203
+ assert_eq!(snap_state["_not_authoritative"], serde_json::json!(true));
204
+
187
205
  let d = detect_dual_state_divergence(&ws, &state)
188
206
  .unwrap()
189
- .expect("divergence detector must read the restart-written snapshot");
207
+ .expect("divergence detector must read the diagnostic snapshot");
190
208
  assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
191
209
  assert_eq!(d["team_owner_pane"], serde_json::json!("%9"));
192
210
  assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
@@ -10,13 +10,16 @@ use super::*;
10
10
  /// (bound-pane liveness + caller eligibility) deterministically with no tmux.
11
11
  #[test]
12
12
  #[serial_test::serial(env)]
13
- fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_dual_writes() {
13
+ // Foundation-0 F0-2 conversion: `claim_lease_no_incident` used to
14
+ // dual-write the legacy per-session snapshot; that is retired
15
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Canonical
16
+ // teams.<team_key> now carries the sole authoritative owner tuple.
17
+ fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_writes_canonical_only() {
14
18
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
15
19
  let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
16
20
  let ws = p2_temp_ws("claim_vacant");
17
21
  let team_id = TeamKey::new("current");
18
22
  let caller = PaneId::new("%5");
19
- // empty (vacant) state with a session so dual-state writes both files.
20
23
  let mut state = serde_json::json!({"session_name": "team-agent-x"});
21
24
  let event_log = crate::event_log::EventLog::new(&ws);
22
25
  let live = seeded_liveness(&["%5"]);
@@ -35,45 +38,25 @@ use super::*;
35
38
  let receiver = r.receiver.as_ref().expect("claimed → receiver");
36
39
  assert_eq!(receiver.pane_id, caller);
37
40
  assert_eq!(receiver.discovery, Some(Discovery::ClaimLeader));
38
- // Stage 3d (identity-boundary unified plan, architect direction
39
- // 2026-06-23): canonical owner now lives at teams.<team_key>; the
40
- // top-level dual-write was dropped. Read through the ownership
41
- // repository which preserves the legacy precedence for callers
42
- // that still consult top-level on migration states.
43
41
  let ws_path = crate::state::persist::runtime_state_path(&ws);
44
42
  let ws_state: serde_json::Value =
45
43
  serde_json::from_str(&std::fs::read_to_string(&ws_path).unwrap()).unwrap();
46
- // The canonical team_key is what `team_state_key(state)` derives
47
- // (here: state's session_name "team-agent-x" since no explicit
48
- // team_key/team_dir/spec_path was seeded). The test's `team_id`
49
- // parameter is the LOOKUP key but `claim_lease_no_incident` uses
50
- // the state-derived key for canonical writes. Stage 5 will unify
51
- // these via TeamRuntimePaths.
52
44
  let canonical_key = crate::state::projection::team_state_key(&ws_state);
53
45
  let ws_owner =
54
46
  crate::state::ownership::read_owner_value(&ws_state, &canonical_key)
55
47
  .expect("Stage 3d: vacant acquire writes canonical owner");
56
48
  assert_eq!(ws_owner["pane_id"], serde_json::json!("%5"));
57
49
  assert_eq!(ws_owner["owner_epoch"], serde_json::json!(1));
58
- // team_id parameter is currently unused by the canonical write
59
- // (architect Stage 5 will close this gap); silence the warning.
60
50
  let _ = &team_id;
51
+ // F0-2: canonical claim path must NOT side-effect the legacy
52
+ // per-session snapshot any more.
61
53
  let snap_path = crate::model::paths::runtime_dir(&ws)
62
54
  .join("teams").join("team-agent-x").join("state.json");
63
- let snap: serde_json::Value =
64
- serde_json::from_str(&std::fs::read_to_string(&snap_path).unwrap()).unwrap();
65
- // Snapshot is per-session; team_state_key on the snapshot derives
66
- // from its session_name. Read top-level first (legacy snapshot
67
- // layout), fall back to derived team key.
68
- let snap_owner = snap
69
- .get("team_owner")
70
- .or_else(|| {
71
- let key = crate::state::projection::team_state_key(&snap);
72
- snap.get("teams").and_then(|t| t.get(key)).and_then(|e| e.get("team_owner"))
73
- })
74
- .expect("dual-state snapshot owner");
75
- assert_eq!(snap_owner["pane_id"], serde_json::json!("%5"), "dual-state snapshot owner");
76
- assert_eq!(snap_owner["owner_epoch"], serde_json::json!(1));
55
+ assert!(
56
+ !snap_path.exists(),
57
+ "F0-2: vacant claim must not write a legacy per-session snapshot; found {}",
58
+ snap_path.display(),
59
+ );
77
60
  }
78
61
 
79
62
  // RED — DEAD-OWNER RECOVER: bound pane %1 absent from live set, caller %5
@@ -6,8 +6,24 @@ use std::path::{Path, PathBuf};
6
6
 
7
7
  use super::*;
8
8
 
9
- /// `save_team_runtime_snapshot(workspace, state)`(`restart/snapshot.py:42`,**bug-084**)。
10
- /// `os.replace` 原子写;`EACCES/EPERM/EBUSY` 退避重试,**绝不 unwrap**。
9
+ /// `save_team_runtime_snapshot(workspace, state)` — Foundation-0 F0-2:
10
+ /// this is now a **diagnostic-only** legacy per-session snapshot writer.
11
+ /// Root/projection `.team/runtime/state.json` is the sole product
12
+ /// authority; the file this writes carries `_not_authoritative:true`
13
+ /// and pointers back to the canonical path so no reader can mistake it
14
+ /// for the real runtime state. See
15
+ /// `.team/artifacts/foundation-0-slice-design.md` §§4-5 F0-2.
16
+ ///
17
+ /// Product save paths (attach/claim/start-agent/restart/stop/remove)
18
+ /// must NOT call this — the RED2 grep guard enforces that. Retained
19
+ /// callers are diagnostic/migration/test paths only.
20
+ ///
21
+ /// Preserves the legacy path shape (`.team/runtime/teams/<session>/state.json`)
22
+ /// so 0.5.x operators inspecting the file see the diagnostic marker
23
+ /// rather than being surprised by a missing file. The B3 canonical
24
+ /// team-key layout will live under a different path
25
+ /// (`teams/<team_key>/...`), so this file also cannot be mistaken for
26
+ /// the future canonical shape.
11
27
  pub fn save_team_runtime_snapshot(
12
28
  workspace: &Path,
13
29
  state: &serde_json::Value,
@@ -16,17 +32,34 @@ pub fn save_team_runtime_snapshot(
16
32
  .get("session_name")
17
33
  .and_then(|v| v.as_str())
18
34
  .ok_or_else(|| LifecycleError::StatePersist("session_name is required".to_string()))?;
19
- // golden restart/snapshot.py:46-47 — team_runtime_snapshot_dir = runtime_dir(workspace)/teams/<safe>,
20
- // and paths.py:25-26 runtime_dir = workspace/.team/runtime. Use the crate path helper so the snapshot
21
- // lands at <ws>/.team/runtime/teams/<safe>, matching golden AND the rest of the crate (not the
22
- // ".team"-less <ws>/runtime/teams that the original port wrote).
23
35
  let path = team_snapshot_path(workspace, session_name);
24
36
  let dir = path
25
37
  .parent()
26
38
  .ok_or_else(|| LifecycleError::StatePersist("snapshot path has no parent".to_string()))?;
27
39
  fs::create_dir_all(&dir).map_err(|e| persist_err("create snapshot dir", &e))?;
40
+ let canonical_path = crate::state::persist::runtime_state_path(workspace);
41
+ let generated_at = chrono::Utc::now().to_rfc3339();
42
+ // F0-2 RED1: annotate the snapshot payload with diagnostic-only
43
+ // metadata so any reader can immediately see it is derived, not
44
+ // authoritative. `_canonical_state_path` points at the real
45
+ // runtime authority; `_derived_from` names the code path that
46
+ // produced this file; `_generated_at` records freshness so stale
47
+ // snapshots are easy to identify.
48
+ let mut annotated = state.clone();
49
+ if let Some(obj) = annotated.as_object_mut() {
50
+ obj.insert("_not_authoritative".to_string(), serde_json::json!(true));
51
+ obj.insert(
52
+ "_canonical_state_path".to_string(),
53
+ serde_json::json!(canonical_path.to_string_lossy()),
54
+ );
55
+ obj.insert(
56
+ "_derived_from".to_string(),
57
+ serde_json::json!("lifecycle::save_team_runtime_snapshot"),
58
+ );
59
+ obj.insert("_generated_at".to_string(), serde_json::json!(generated_at));
60
+ }
28
61
  let tmp = dir.join("state.json.tmp");
29
- let data = serde_json::to_vec_pretty(state)
62
+ let data = serde_json::to_vec_pretty(&annotated)
30
63
  .map_err(|e| LifecycleError::StatePersist(format!("serialize snapshot: {e}")))?;
31
64
  fs::write(&tmp, data).map_err(|e| persist_err("write snapshot temp", &e))?;
32
65
  fs::rename(&tmp, &path).map_err(|e| persist_err("replace snapshot", &e))?;
@@ -915,12 +915,9 @@ pub(super) fn stop_agent_at_paths(
915
915
  )
916
916
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
917
917
  // golden operations.py:96-99: snapshot (side-effect), then state_file = write_team_state path.
918
- // snapshot.py:19-21 returns None silently when session_name is falsy — mirror that no-op here so
919
- // a workspace whose persisted state lacks session_name proceeds to write team_state and return ok
920
- // (do NOT swallow OTHER snapshot errors). The stop/reset/remove-force paths all inherit this guard.
921
- if session_name_present(&state) {
922
- crate::lifecycle::helpers::save_team_runtime_snapshot(workspace, &state)?;
923
- }
918
+ // Foundation-0 F0-2: legacy per-session snapshot dual-write retired
919
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Root state
920
+ // remains the only save target on the stop path.
924
921
  let state_file = write_team_state(spec_workspace, &spec, &state)?;
925
922
  write_stop_complete_event(workspace, agent_id, &target_str, stopped)?;
926
923
  Ok(StopAgentReport {
@@ -186,17 +186,12 @@ fn remove_agent_at_paths(
186
186
  );
187
187
  match result {
188
188
  Ok(success) => {
189
- // golden agents.py:135: _save_team_runtime_snapshot runs OUTSIDE the try/except, and
190
- // snapshot.py:19-21 returns None (no error) when session_name is falsy. Mirror that here:
191
- // only snapshot when session_name is present, and never let it roll the committed removal back.
192
- if success
193
- .removed_state
194
- .get("session_name")
195
- .and_then(|v| v.as_str())
196
- .is_some_and(|s| !s.is_empty())
197
- {
198
- let _ = crate::lifecycle::helpers::save_team_runtime_snapshot(workspace, &success.removed_state)?;
199
- }
189
+ // Foundation-0 F0-2: the historical dual-write to the legacy
190
+ // per-session snapshot has been retired
191
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
192
+ // Root/projection is the sole runtime authority; the
193
+ // snapshot writer stayed in `lifecycle::helpers` only for
194
+ // diagnostic/migration/test callers.
200
195
  write_remove_complete_event(
201
196
  paths.run_workspace,
202
197
  agent_id,
@@ -280,8 +280,10 @@ pub(crate) fn latest_reportable_message_for(
280
280
  agent_id: &str,
281
281
  owner_team_id: Option<&str>,
282
282
  ) -> Option<String> {
283
- current_reportable_message_for(workspace, agent_id, owner_team_id)
284
- .or_else(|| latest_delivered_direct_message_for(workspace, agent_id, owner_team_id))
283
+ match direct_message_attribution_for(workspace, agent_id, owner_team_id) {
284
+ DirectMessageAttribution::Reportable(message_id) => Some(message_id),
285
+ DirectMessageAttribution::BlockedByNewer { .. } | DirectMessageAttribution::None => None,
286
+ }
285
287
  }
286
288
 
287
289
  pub(crate) fn current_reportable_message_for(
@@ -306,6 +308,49 @@ pub(crate) fn latest_delivered_direct_message_for(
306
308
  latest_reportable_message_from_db(&conn, agent_id, owner_team_id)
307
309
  }
308
310
 
311
+ #[derive(Debug, Clone, PartialEq, Eq)]
312
+ pub(crate) enum DirectMessageAttribution {
313
+ Reportable(String),
314
+ BlockedByNewer {
315
+ message_id: String,
316
+ status: String,
317
+ error: Option<String>,
318
+ },
319
+ None,
320
+ }
321
+
322
+ pub(crate) fn direct_message_attribution_for(
323
+ workspace: &Path,
324
+ agent_id: &str,
325
+ owner_team_id: Option<&str>,
326
+ ) -> DirectMessageAttribution {
327
+ use crate::db::message_store::MessageStore;
328
+ let Ok(store) = MessageStore::open(workspace) else {
329
+ return DirectMessageAttribution::None;
330
+ };
331
+ let Ok(conn) = crate::db::schema::open_db(store.db_path()) else {
332
+ return DirectMessageAttribution::None;
333
+ };
334
+ if let Some(message_id) = current_turn_message_for(workspace, &conn, agent_id, owner_team_id) {
335
+ return DirectMessageAttribution::Reportable(message_id);
336
+ }
337
+ match latest_direct_message_from_db(&conn, agent_id, owner_team_id) {
338
+ Some(candidate)
339
+ if reportable_message_status(&candidate.status, candidate.error.as_deref()) =>
340
+ {
341
+ DirectMessageAttribution::Reportable(candidate.message_id)
342
+ }
343
+ Some(candidate) if blocks_historical_fallback(&candidate.status) => {
344
+ DirectMessageAttribution::BlockedByNewer {
345
+ message_id: candidate.message_id,
346
+ status: candidate.status,
347
+ error: candidate.error,
348
+ }
349
+ }
350
+ Some(_) | None => DirectMessageAttribution::None,
351
+ }
352
+ }
353
+
309
354
  fn current_turn_message_for(
310
355
  workspace: &Path,
311
356
  conn: &rusqlite::Connection,
@@ -419,9 +464,27 @@ fn latest_reportable_message_from_db(
419
464
  agent_id: &str,
420
465
  owner_team_id: Option<&str>,
421
466
  ) -> Option<String> {
422
- let row = conn
423
- .query_row(
424
- "select m.message_id, m.status, m.error from messages m
467
+ let candidate = latest_direct_message_from_db(conn, agent_id, owner_team_id)?;
468
+ if reportable_message_status(&candidate.status, candidate.error.as_deref()) {
469
+ Some(candidate.message_id)
470
+ } else {
471
+ None
472
+ }
473
+ }
474
+
475
+ struct DirectMessageCandidate {
476
+ message_id: String,
477
+ status: String,
478
+ error: Option<String>,
479
+ }
480
+
481
+ fn latest_direct_message_from_db(
482
+ conn: &rusqlite::Connection,
483
+ agent_id: &str,
484
+ owner_team_id: Option<&str>,
485
+ ) -> Option<DirectMessageCandidate> {
486
+ conn.query_row(
487
+ "select m.message_id, m.status, m.error from messages m
425
488
  where m.recipient = ?1
426
489
  and (?2 is null or m.owner_team_id = ?2)
427
490
  and (m.task_id is null or m.task_id = '')
@@ -437,29 +500,25 @@ fn latest_reportable_message_from_db(
437
500
  order by m.created_at desc,
438
501
  case when m.status = 'delivered' then 0 else 1 end
439
502
  limit 1",
440
- params![agent_id, owner_team_id],
441
- |row| {
442
- Ok((
443
- row.get::<_, String>(0)?,
444
- row.get::<_, String>(1)?,
445
- row.get::<_, Option<String>>(2)?,
446
- ))
447
- },
448
- )
449
- .optional()
450
- .ok()
451
- .flatten()?;
452
- let (message_id, status, error) = row;
453
- if reportable_message_status(&status, error.as_deref()) {
454
- Some(message_id)
455
- } else {
456
- None
457
- }
503
+ params![agent_id, owner_team_id],
504
+ |row| {
505
+ Ok(DirectMessageCandidate {
506
+ message_id: row.get::<_, String>(0)?,
507
+ status: row.get::<_, String>(1)?,
508
+ error: row.get::<_, Option<String>>(2)?,
509
+ })
510
+ },
511
+ )
512
+ .optional()
513
+ .ok()
514
+ .flatten()
458
515
  }
459
516
 
460
517
  fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
461
- matches!(
462
- status,
463
- "delivered" | "submitted" | "injected" | "visible"
464
- ) && (status == "delivered" || error.is_none())
518
+ matches!(status, "delivered" | "submitted" | "injected" | "visible")
519
+ && (status == "delivered" || error.is_none())
520
+ }
521
+
522
+ fn blocks_historical_fallback(status: &str) -> bool {
523
+ matches!(status, "failed" | "refused" | "blocked")
465
524
  }
@@ -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>,
@@ -20,10 +20,10 @@ use crate::state::persist::{
20
20
  use crate::messaging::{self, MessageTarget, SendOptions};
21
21
 
22
22
  use super::helpers::{
23
- current_reportable_message_for, delivery_outcome_value, ensure_object, enum_value,
24
- insert_array, is_worker_recipient, json_dumps_default, latest_delivered_direct_message_for,
25
- latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
26
- requires_ack_for_target, tool_runtime_error,
23
+ delivery_outcome_value, direct_message_attribution_for, ensure_object, enum_value,
24
+ insert_array, is_worker_recipient, json_dumps_default, latest_task_for_assignee,
25
+ non_empty_string, normalized_envelope_value, object_fields, requires_ack_for_target,
26
+ tool_runtime_error, DirectMessageAttribution,
27
27
  };
28
28
  use super::normalize::{
29
29
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
@@ -336,41 +336,49 @@ impl TeamOrchestratorTools {
336
336
  // message-scoped fallback, before defaulting to "manual".
337
337
  // 1. explicit arg
338
338
  // 2. current physical direct turn for this agent
339
- // 3. latest delivered direct message with no result yet
340
- // 4. latest nonterminal assigned task in teams.<owner>.tasks
341
- // (scoped) top-level tasks
339
+ // 3. bounded latest reportable direct message with no result yet
340
+ // 4. latest nonterminal assigned task in teams.<owner>.tasks,
341
+ // only when no newer failed/refused/blocked direct turn blocks fallback
342
342
  // 5. "manual" — truly uncorrelated; collect still rejects
343
343
  let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
344
- let resolved = task_id
345
- .map(ToString::to_string)
346
- .or_else(|| {
347
- self.agent_id.as_ref().and_then(|agent| {
348
- current_reportable_message_for(
349
- &self.workspace,
350
- agent.as_str(),
351
- owner_team_id_str.as_deref(),
352
- )
353
- })
354
- })
355
- .or_else(|| {
356
- self.agent_id.as_ref().and_then(|agent| {
357
- latest_delivered_direct_message_for(
358
- &self.workspace,
359
- agent.as_str(),
360
- owner_team_id_str.as_deref(),
361
- )
362
- })
363
- })
364
- .or_else(|| {
365
- self.agent_id.as_ref().and_then(|agent| {
366
- latest_task_for_assignee(
367
- &self.workspace,
368
- agent.as_str(),
369
- owner_team_id_str.as_deref(),
370
- )
371
- })
372
- })
373
- .unwrap_or_else(|| "manual".to_string());
344
+ let resolved = if let Some(task_id) = task_id {
345
+ task_id.to_string()
346
+ } else if let Some(agent) = self.agent_id.as_ref() {
347
+ match direct_message_attribution_for(
348
+ &self.workspace,
349
+ agent.as_str(),
350
+ owner_team_id_str.as_deref(),
351
+ ) {
352
+ DirectMessageAttribution::Reportable(message_id) => message_id,
353
+ DirectMessageAttribution::BlockedByNewer {
354
+ message_id,
355
+ status,
356
+ error,
357
+ } => {
358
+ push_report_warning(
359
+ obj,
360
+ serde_json::json!({
361
+ "code": "result_attribution_blocked_by_newer_direct_message",
362
+ "field": "task_id",
363
+ "severity": "warning",
364
+ "advisory": true,
365
+ "message_id": message_id,
366
+ "message_status": status,
367
+ "message_error": error,
368
+ }),
369
+ );
370
+ "manual".to_string()
371
+ }
372
+ DirectMessageAttribution::None => latest_task_for_assignee(
373
+ &self.workspace,
374
+ agent.as_str(),
375
+ owner_team_id_str.as_deref(),
376
+ )
377
+ .unwrap_or_else(|| "manual".to_string()),
378
+ }
379
+ } else {
380
+ "manual".to_string()
381
+ };
374
382
  obj.insert("task_id".to_string(), Value::String(resolved));
375
383
  }
376
384
  if !obj.contains_key("agent_id") {
@@ -977,6 +985,29 @@ fn merge_object_fields(existing: &mut Value, incoming: &Value) {
977
985
  }
978
986
  }
979
987
 
988
+ fn push_report_warning(obj: &mut serde_json::Map<String, Value>, warning: Value) {
989
+ let Some(code) = warning.get("code").and_then(Value::as_str) else {
990
+ return;
991
+ };
992
+ let Some(field) = warning.get("field").and_then(Value::as_str) else {
993
+ return;
994
+ };
995
+ match obj.get_mut("warnings") {
996
+ Some(Value::Array(warnings)) => {
997
+ let exists = warnings.iter().any(|existing| {
998
+ existing.get("code").and_then(Value::as_str) == Some(code)
999
+ && existing.get("field").and_then(Value::as_str) == Some(field)
1000
+ });
1001
+ if !exists {
1002
+ warnings.push(warning);
1003
+ }
1004
+ }
1005
+ _ => {
1006
+ obj.insert("warnings".to_string(), Value::Array(vec![warning]));
1007
+ }
1008
+ }
1009
+ }
1010
+
980
1011
  fn write_team_tasks(state: &mut Value, team_key: &str, tasks: Vec<Value>) {
981
1012
  let Some(root) = state.as_object_mut() else {
982
1013
  return;
@@ -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.22",
3
+ "version": "0.5.24",
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.22",
24
- "@team-agent/cli-darwin-x64": "0.5.22",
25
- "@team-agent/cli-linux-x64": "0.5.22"
23
+ "@team-agent/cli-darwin-arm64": "0.5.24",
24
+ "@team-agent/cli-darwin-x64": "0.5.24",
25
+ "@team-agent/cli-linux-x64": "0.5.24"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",