@team-agent/installer 0.4.1 → 0.4.3

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.
Files changed (44) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +82 -22
  4. package/crates/team-agent/src/cli/diagnose.rs +6 -2
  5. package/crates/team-agent/src/cli/emit.rs +301 -38
  6. package/crates/team-agent/src/cli/mod.rs +7 -14
  7. package/crates/team-agent/src/cli/status_port.rs +12 -1
  8. package/crates/team-agent/src/cli/tests/base.rs +6 -2
  9. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  10. package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
  12. package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
  13. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
  15. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  16. package/crates/team-agent/src/cli/types.rs +35 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
  18. package/crates/team-agent/src/diagnose/comms.rs +10 -2
  19. package/crates/team-agent/src/leader/lease.rs +98 -19
  20. package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
  21. package/crates/team-agent/src/leader/rediscover.rs +16 -7
  22. package/crates/team-agent/src/leader/start.rs +25 -12
  23. package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
  24. package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
  25. package/crates/team-agent/src/lifecycle/launch.rs +126 -100
  26. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +40 -12
  27. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
  29. package/crates/team-agent/src/lifecycle/types.rs +1 -1
  30. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
  31. package/crates/team-agent/src/messaging/send.rs +21 -4
  32. package/crates/team-agent/src/provider/adapter.rs +72 -0
  33. package/crates/team-agent/src/provider/session/capture.rs +228 -0
  34. package/crates/team-agent/src/state/identity.rs +42 -12
  35. package/crates/team-agent/src/state/identity_keys.rs +134 -0
  36. package/crates/team-agent/src/state/mod.rs +8 -0
  37. package/crates/team-agent/src/state/owner_gate.rs +11 -1
  38. package/crates/team-agent/src/state/ownership.rs +556 -0
  39. package/crates/team-agent/src/state/paths.rs +358 -0
  40. package/crates/team-agent/src/state/persist.rs +43 -5
  41. package/crates/team-agent/src/state/projection.rs +13 -5
  42. package/crates/team-agent/src/tmux_backend.rs +12 -0
  43. package/package.json +4 -4
  44. package/skills/team-agent/SKILL.md +3 -3
@@ -230,16 +230,23 @@ pub fn claim_leader_receiver(
230
230
  "runtime state root is not an object".to_string(),
231
231
  ));
232
232
  };
233
- let owner = root
234
- .entry("team_owner")
235
- .or_insert_with(|| serde_json::json!({}));
236
- if let Some(owner) = owner.as_object_mut() {
233
+ // Build the updated owner + receiver values in-place (field-level
234
+ // mutation preserves any extra fields the caller carries), then publish
235
+ // through the ownership repository. This keeps `claim_leader_receiver`'s
236
+ // legacy field-merge semantics while sourcing all owner mutations from
237
+ // one API surface (Stage 3a, architect direction 2026-06-23).
238
+ let mut owner_value = root
239
+ .get("team_owner")
240
+ .cloned()
241
+ .unwrap_or_else(|| serde_json::json!({}));
242
+ if let Some(owner) = owner_value.as_object_mut() {
237
243
  owner.insert("owner_epoch".to_string(), serde_json::json!(next_epoch));
238
244
  }
239
- let receiver = root
240
- .entry("leader_receiver")
241
- .or_insert_with(|| serde_json::json!({}));
242
- if let Some(receiver) = receiver.as_object_mut() {
245
+ let mut receiver_value = root
246
+ .get("leader_receiver")
247
+ .cloned()
248
+ .unwrap_or_else(|| serde_json::json!({}));
249
+ if let Some(receiver) = receiver_value.as_object_mut() {
243
250
  receiver.insert("mode".to_string(), serde_json::json!("direct_tmux"));
244
251
  receiver.insert("owner_epoch".to_string(), serde_json::json!(next_epoch));
245
252
  copy_candidate_field(receiver, candidate, "pane_id");
@@ -255,6 +262,13 @@ pub fn claim_leader_receiver(
255
262
  receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
256
263
  }
257
264
  }
265
+ // The `root` borrow above is released by NLL before this call.
266
+ let team_key = crate::state::projection::team_state_key(state);
267
+ let record = crate::state::ownership::OwnershipWrite::new()
268
+ .with_team_owner(owner_value)
269
+ .with_leader_receiver(receiver_value)
270
+ .with_owner_epoch(next_epoch);
271
+ crate::state::ownership::write_owner(state, &team_key, record);
258
272
  crate::state::persist::save_runtime_state(workspace, state)?;
259
273
  event_log.write(
260
274
  "leader_receiver.claimed",
@@ -357,16 +357,28 @@ fn backfill_leader_binding_for_delivery_view(
357
357
  let Some(obj) = state.as_object_mut() else {
358
358
  return;
359
359
  };
360
+ // Stage 3a (identity-boundary unified plan, architect direction
361
+ // 2026-06-23): route the in-memory backfill through the ownership
362
+ // repository. This in-memory promote is a delivery-view helper, NOT a
363
+ // persistent write — but funneling it through the same API as the
364
+ // canonical writers keeps the writer surface consistent for the
365
+ // acceptance contract (architect §6 verdict 2: zero `insert("team_owner")`
366
+ // outside ownership module). The repository call only touches the
367
+ // top-level (we pass empty team_key) so it matches the pre-3a backfill
368
+ // semantics exactly: top-level fields populated when missing, no
369
+ // teams-projection write.
370
+ let mut record = crate::state::ownership::OwnershipWrite::new();
360
371
  if !obj.contains_key("leader_receiver") {
361
372
  if let Some(receiver) = raw_state.get("leader_receiver").filter(|v| !v.is_null()) {
362
- obj.insert("leader_receiver".to_string(), receiver.clone());
373
+ record = record.with_leader_receiver(receiver.clone());
363
374
  }
364
375
  }
365
376
  if !obj.contains_key("team_owner") {
366
377
  if let Some(owner) = raw_state.get("team_owner").filter(|v| !v.is_null()) {
367
- obj.insert("team_owner".to_string(), owner.clone());
378
+ record = record.with_team_owner(owner.clone());
368
379
  }
369
380
  }
381
+ crate::state::ownership::write_owner(state, "", record);
370
382
  }
371
383
 
372
384
  fn can_backfill_top_level_leader_binding(raw_state: &serde_json::Value) -> bool {
@@ -459,8 +471,13 @@ fn owner_pane_is_dead(state: &serde_json::Value) -> bool {
459
471
  {
460
472
  return true;
461
473
  }
462
- let Some(pane_id) = state
463
- .get("team_owner")
474
+ // Stage 3a (identity-boundary unified plan, architect direction
475
+ // 2026-06-23): route owner read through the ownership repository.
476
+ // `state` here is already team-projected by the upstream
477
+ // `send_message` flow, so the empty-team-key path returns the same
478
+ // top-level owner the legacy direct read produced. Stage 5 will swap
479
+ // the data source under the repository.
480
+ let Some(pane_id) = crate::state::ownership::read_owner_value(state, "")
464
481
  .and_then(|owner| owner.get("pane_id"))
465
482
  .and_then(serde_json::Value::as_str)
466
483
  .filter(|pane| !pane.is_empty())
@@ -1062,6 +1062,30 @@ fn scan_session_candidates_once(
1062
1062
  }) {
1063
1063
  return Ok(vec![hit.clone()]);
1064
1064
  }
1065
+ // Stage 1 (identity-boundary unified plan, architect direction
1066
+ // 2026-06-23): for Claude/ClaudeCode with an expected session id,
1067
+ // an exact-id miss MUST NOT fall back to same-cwd latest. Pre-fix
1068
+ // the scanner returned every same-cwd candidate sorted with
1069
+ // expected-first; with no exact match, that handed `frontend`'s
1070
+ // transcript to `reviewer` in the AI-sync repro. Mirror Copilot's
1071
+ // stricter contract: keep only candidates with a positive worker
1072
+ // identity match (TEAM_AGENT_ID literal in the transcript head, or
1073
+ // path-encoded agent_id). If none, return empty — capture stays
1074
+ // pending/ambiguous, which is safer than misattribution.
1075
+ //
1076
+ // Bug 2 (0.4.2 P0) reinforcement: even when positive-identity
1077
+ // filtering returns no candidates, do NOT let downstream
1078
+ // time-window narrowing run for Claude when expected_session_id
1079
+ // misses — that's exactly the leader-session-leak symptom the
1080
+ // architect cataloged in .team/artifacts/bug-042-restart-scope-and-session.md.
1081
+ if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
1082
+ let positive_only: Vec<CapturedSessionCandidate> = out
1083
+ .iter()
1084
+ .filter(|candidate| candidate.positive_agent_id_match || candidate.agent_path_match)
1085
+ .cloned()
1086
+ .collect();
1087
+ return Ok(positive_only);
1088
+ }
1065
1089
  }
1066
1090
  // E6 层1·B(主路径,交互式现实):cwd 匹配但盘上有多个 sibling transcript(claude 自生成,
1067
1091
  // 不采用预定 UUID)→ 用 spawn 时间窗唯一选:只留 mtime >= spawned_at 的候选,打破歧义。
@@ -1086,6 +1110,10 @@ fn scan_session_candidates_once(
1086
1110
  }
1087
1111
  }
1088
1112
  }
1113
+ // Non-Claude / non-strict providers with an expected id but no exact
1114
+ // match: order expected-first so the allocator's `unique_available_candidate`
1115
+ // sees the deterministically preferred candidate. Claude/ClaudeCode took
1116
+ // the strict positive-only return above and never reaches here.
1089
1117
  if let Some(expected) = context.expected_session_id.as_ref() {
1090
1118
  out.sort_by_key(|candidate| {
1091
1119
  candidate
@@ -2022,6 +2050,50 @@ mod e6_session_attribution_tests {
2022
2050
  f.set_modified(when).unwrap();
2023
2051
  }
2024
2052
 
2053
+ /// Bug 2 (0.4.2 P0): when expected_session_id is set (restart --allow-fresh
2054
+ /// pre-allocates a UUID via --session-id) but the Claude CLI didn't adopt
2055
+ /// it (no <expected>.jsonl on disk), capture must NOT fall back to
2056
+ /// time-window narrowing over leader transcripts sharing the same cwd.
2057
+ /// Pre-fix: leader transcript with later mtime would be silently picked
2058
+ /// up as the worker session. Post-fix: empty list returned (caller treats
2059
+ /// as session-not-yet-captured and retries on next tick).
2060
+ #[test]
2061
+ fn scan_expected_session_id_miss_refuses_to_pick_leader_sibling() {
2062
+ let base = tmp_root("strict-no-leader-fallback");
2063
+ let cwd = base.join("ws");
2064
+ std::fs::create_dir_all(&cwd).unwrap();
2065
+ let proj = base.join("projects");
2066
+ // Simulate the leader's transcript and a stale earlier transcript in
2067
+ // the same cwd. NEITHER matches expected_session_id.
2068
+ let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
2069
+ let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
2070
+ let _ = (leader, stale);
2071
+ let ctx = CaptureSessionContext {
2072
+ agent_id: "claude-worker".to_string(),
2073
+ spawn_cwd: cwd.clone(),
2074
+ pane_id: None,
2075
+ pane_pid: None,
2076
+ spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
2077
+ // Worker was spawned with --session-id <expected> but Claude
2078
+ // didn't write <expected>.jsonl.
2079
+ expected_session_id: Some(SessionId::new(
2080
+ "99999999-9999-4999-8999-999999999999",
2081
+ )),
2082
+ provider_projects_root: Some(proj.clone()),
2083
+ };
2084
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
2085
+ assert!(
2086
+ out.is_empty(),
2087
+ "expected_session_id set + no exact match → must NOT fall back to \
2088
+ time-window narrowing (would grab leader's transcript). \
2089
+ got={:?}",
2090
+ out.iter()
2091
+ .filter_map(|c| c.captured.session_id.as_ref().map(|s| s.as_str().to_string()))
2092
+ .collect::<Vec<_>>()
2093
+ );
2094
+ let _ = std::fs::remove_dir_all(&base);
2095
+ }
2096
+
2025
2097
  // ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
2026
2098
  struct HomeGuard {
2027
2099
  prev: Option<std::ffi::OsString>,
@@ -194,6 +194,40 @@ where
194
194
  continue;
195
195
  };
196
196
  if let Some(candidate) = assignments.get(&item.agent_id) {
197
+ // Stage 1 (identity-boundary unified plan, architect direction
198
+ // 2026-06-23): defensive expected-id guard. The adapter scanner
199
+ // is the primary defence (Claude no longer falls back to same-
200
+ // cwd latest when expected_session_id is set), but the allocator
201
+ // also goes through a one-to-one global pass that could match a
202
+ // wrong candidate if the per-agent list still has stale entries.
203
+ // Refuse to write `session_id`/`rollout_path` when the candidate's
204
+ // session_id is set AND differs from the pending expected_session_id.
205
+ // Capture stays pending; the agent is marked ambiguous so the
206
+ // operator sees it.
207
+ let mismatch = item
208
+ .context
209
+ .expected_session_id
210
+ .as_ref()
211
+ .zip(candidate.captured.session_id.as_ref())
212
+ .is_some_and(|(expected, captured)| expected.as_str() != captured.as_str());
213
+ if mismatch {
214
+ report.ambiguous.push(AmbiguousSessionCapture {
215
+ agent_id: item.agent_id.clone(),
216
+ spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
217
+ });
218
+ if finalize_ambiguous {
219
+ agent_obj.insert(
220
+ "attribution_ambiguous".to_string(),
221
+ serde_json::json!(true),
222
+ );
223
+ agent_obj.insert(
224
+ "captured_at".to_string(),
225
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
226
+ );
227
+ report.changed = true;
228
+ }
229
+ continue;
230
+ }
197
231
  apply_captured_session(agent_obj, &candidate.captured);
198
232
  report.changed = true;
199
233
  report.assigned.push(item.agent_id);
@@ -499,7 +533,54 @@ fn allocate_session_candidates(
499
533
  ) -> (BTreeMap<String, CapturedSessionCandidate>, BTreeSet<String>) {
500
534
  let mut assignments = BTreeMap::new();
501
535
  let mut ambiguous = BTreeSet::new();
536
+
537
+ // Stage 1 amendment (architect direction 2026-06-23, S1-CAPTURE-002 fix):
538
+ // ExpectedSessionId pre-pass. For each pending agent that carries an
539
+ // `_pending_session_id`, the strongest possible binding is the candidate
540
+ // whose `session_id` exactly matches that expected id. Run this BEFORE
541
+ // the existing PositiveAgentId / PathAgentId / global one-to-one passes,
542
+ // because the global one-to-one pass uses
543
+ // `remaining_agents.zip(candidates.into_values())` (BTreeMap sorted by
544
+ // candidate-key, not by agent ownership) and can produce CROSS
545
+ // assignments (claude-a → claude-b's transcript and vice versa) when two
546
+ // pending agents both have expected ids but no PositiveAgentId/PathAgentId
547
+ // hint — those would be rejected by the mismatch guard at apply time,
548
+ // leaving both agents `attribution_ambiguous` instead of correctly
549
+ // bound. With this pre-pass the global one-to-one only ever sees agents
550
+ // that have NO expected id (or whose expected candidate is unavailable
551
+ // / collides), and the cross-assignment hazard is eliminated.
502
552
  for item in pending {
553
+ let Some(expected) = item.context.expected_session_id.as_ref() else {
554
+ continue;
555
+ };
556
+ let Some(agent_candidates) = candidates_by_agent.get(&item.agent_id) else {
557
+ continue;
558
+ };
559
+ let exact_matches: Vec<&CapturedSessionCandidate> = agent_candidates
560
+ .iter()
561
+ .filter(|candidate| {
562
+ candidate
563
+ .captured
564
+ .session_id
565
+ .as_ref()
566
+ .is_some_and(|sid| sid.as_str() == expected.as_str())
567
+ })
568
+ .filter(|candidate| !candidate_keys_collide(candidate, claimed))
569
+ .collect();
570
+ // Uniqueness requirement: only assign when the expected id maps to
571
+ // exactly one available candidate. Multiple matches or a colliding
572
+ // single match leave the agent for the ambiguity path below.
573
+ if exact_matches.len() == 1 {
574
+ let candidate = exact_matches[0].clone();
575
+ claimed.extend(captured_provider_session_keys(&candidate.captured));
576
+ assignments.insert(item.agent_id.clone(), candidate);
577
+ }
578
+ }
579
+
580
+ for item in pending {
581
+ if assignments.contains_key(&item.agent_id) {
582
+ continue;
583
+ }
503
584
  if let Some(candidate) = unique_available_candidate(
504
585
  candidates_by_agent.get(&item.agent_id),
505
586
  claimed,
@@ -711,12 +792,19 @@ fn parse_provider(raw: &str) -> Option<Provider> {
711
792
  #[cfg(test)]
712
793
  pub(crate) mod test_support {
713
794
  use super::*;
795
+ use std::sync::Arc;
714
796
 
715
797
  #[derive(Clone)]
716
798
  pub(crate) struct CaptureCandidatesAdapter {
717
799
  provider: Provider,
718
800
  fail_agent_id: Option<String>,
719
801
  error: String,
802
+ /// Stage 1 amendment test support (architect direction 2026-06-23):
803
+ /// per-agent candidate map. When set, the adapter returns the mapped
804
+ /// candidates for `context.agent_id` instead of an empty list. Lets
805
+ /// the allocator-level test inject expected-id candidates for two
806
+ /// pending agents and observe cross-assignment regressions.
807
+ candidates_by_agent: Option<Arc<BTreeMap<String, Vec<CapturedSessionCandidate>>>>,
720
808
  }
721
809
 
722
810
  impl CaptureCandidatesAdapter {
@@ -725,8 +813,17 @@ pub(crate) mod test_support {
725
813
  provider,
726
814
  fail_agent_id: fail_agent_id.map(str::to_string),
727
815
  error: error.to_string(),
816
+ candidates_by_agent: None,
728
817
  }
729
818
  }
819
+
820
+ pub(crate) fn with_candidates(
821
+ mut self,
822
+ candidates_by_agent: BTreeMap<String, Vec<CapturedSessionCandidate>>,
823
+ ) -> Self {
824
+ self.candidates_by_agent = Some(Arc::new(candidates_by_agent));
825
+ self
826
+ }
730
827
  }
731
828
 
732
829
  impl ProviderAdapter for CaptureCandidatesAdapter {
@@ -802,6 +899,11 @@ pub(crate) mod test_support {
802
899
  if self.fail_agent_id.as_deref() == Some(context.agent_id.as_str()) {
803
900
  return Err(ProviderError::Io(self.error.clone()));
804
901
  }
902
+ if let Some(map) = self.candidates_by_agent.as_ref() {
903
+ if let Some(candidates) = map.get(&context.agent_id) {
904
+ return Ok(candidates.clone());
905
+ }
906
+ }
805
907
  Ok(Vec::new())
806
908
  }
807
909
 
@@ -1007,4 +1109,130 @@ mod u1_tests {
1007
1109
  });
1008
1110
  assert!(!agent_session_complete(&agent_no_path));
1009
1111
  }
1112
+
1113
+ /// Stage 1 amendment regression (architect direction 2026-06-23,
1114
+ /// S1-CAPTURE-002): two pending agents, each with a distinct
1115
+ /// `_pending_session_id`. Each agent's candidate list contains ONLY its
1116
+ /// own expected transcript (no positive worker identity hint, no path
1117
+ /// agent id hint — the file basename is just a UUID). Pre-fix, the
1118
+ /// allocator's `allocate_global_one_to_one` zipped agents (sorted by
1119
+ /// agent_id) with candidates (sorted by candidate-key), producing CROSS
1120
+ /// assignments that the mismatch guard then rejected — leaving both
1121
+ /// agents `attribution_ambiguous`. Post-fix, the ExpectedSessionId
1122
+ /// pre-pass binds each agent to its own expected candidate before any
1123
+ /// global one-to-one runs.
1124
+ #[test]
1125
+ fn capture_allocator_expected_session_id_binds_each_worker_to_its_own_transcript() {
1126
+ use crate::provider::{CaptureVia, Confidence, RolloutPath};
1127
+ let dir = std::env::temp_dir().join(format!(
1128
+ "ta-stage1-allocator-{}",
1129
+ std::process::id()
1130
+ ));
1131
+ std::fs::create_dir_all(&dir).unwrap();
1132
+ let rollout_a = dir.join("9a2d1668.jsonl");
1133
+ let rollout_b = dir.join("3e824e89.jsonl");
1134
+ std::fs::write(&rollout_a, b"{}\n").unwrap();
1135
+ std::fs::write(&rollout_b, b"{}\n").unwrap();
1136
+ let candidate_a = CapturedSessionCandidate {
1137
+ captured: CapturedSession {
1138
+ session_id: Some(SessionId::new(
1139
+ "9a2d1668-8987-4c36-8bde-a5135b10da02",
1140
+ )),
1141
+ rollout_path: Some(RolloutPath::new(rollout_a.clone())),
1142
+ captured_via: CaptureVia::FsWatch,
1143
+ attribution_confidence: Confidence::High,
1144
+ spawn_cwd: dir.clone(),
1145
+ },
1146
+ // Critical: no positive worker identity hint. Pre-fix this is
1147
+ // exactly the shape that fell through to global one-to-one.
1148
+ positive_agent_id_match: false,
1149
+ agent_path_match: false,
1150
+ };
1151
+ let candidate_b = CapturedSessionCandidate {
1152
+ captured: CapturedSession {
1153
+ session_id: Some(SessionId::new(
1154
+ "3e824e89-25ac-4b3f-b272-b4f733f6403c",
1155
+ )),
1156
+ rollout_path: Some(RolloutPath::new(rollout_b.clone())),
1157
+ captured_via: CaptureVia::FsWatch,
1158
+ attribution_confidence: Confidence::High,
1159
+ spawn_cwd: dir.clone(),
1160
+ },
1161
+ positive_agent_id_match: false,
1162
+ agent_path_match: false,
1163
+ };
1164
+ let mut candidates_by_agent: BTreeMap<String, Vec<CapturedSessionCandidate>> =
1165
+ BTreeMap::new();
1166
+ candidates_by_agent.insert("claude-a".to_string(), vec![candidate_a.clone()]);
1167
+ candidates_by_agent.insert("claude-b".to_string(), vec![candidate_b.clone()]);
1168
+
1169
+ let cwd_str = dir.to_string_lossy().to_string();
1170
+ let mut state = serde_json::json!({
1171
+ "agents": {
1172
+ "claude-a": {
1173
+ "provider": "claude",
1174
+ "status": "running",
1175
+ "spawn_cwd": cwd_str,
1176
+ "_pending_session_id": "9a2d1668-8987-4c36-8bde-a5135b10da02"
1177
+ },
1178
+ "claude-b": {
1179
+ "provider": "claude",
1180
+ "status": "running",
1181
+ "spawn_cwd": cwd_str,
1182
+ "_pending_session_id": "3e824e89-25ac-4b3f-b272-b4f733f6403c"
1183
+ }
1184
+ }
1185
+ });
1186
+ let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Claude, None, "")
1187
+ .with_candidates(candidates_by_agent);
1188
+ let mut adapter_for = move |_provider| {
1189
+ Box::new(adapter.clone()) as Box<dyn ProviderAdapter>
1190
+ };
1191
+
1192
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
1193
+ .expect("allocator pass should succeed");
1194
+
1195
+ // ExpectedSessionId pre-pass must bind each agent to its own
1196
+ // expected transcript. No cross-assignment, no ambiguous mark.
1197
+ let agents = state["agents"].as_object().expect("agents object");
1198
+ assert_eq!(
1199
+ agents["claude-a"]["session_id"].as_str(),
1200
+ Some("9a2d1668-8987-4c36-8bde-a5135b10da02"),
1201
+ "Stage 1 fix: claude-a must bind to its own expected transcript; \
1202
+ state.claude-a={}",
1203
+ agents["claude-a"]
1204
+ );
1205
+ assert_eq!(
1206
+ agents["claude-b"]["session_id"].as_str(),
1207
+ Some("3e824e89-25ac-4b3f-b272-b4f733f6403c"),
1208
+ "Stage 1 fix: claude-b must bind to its own expected transcript; \
1209
+ state.claude-b={}",
1210
+ agents["claude-b"]
1211
+ );
1212
+ assert!(
1213
+ agents["claude-a"]
1214
+ .get("attribution_ambiguous")
1215
+ .is_none_or(|v| v.as_bool() != Some(true)),
1216
+ "Stage 1 fix: claude-a must NOT be flagged ambiguous after the \
1217
+ expected-id pre-pass bound it; state.claude-a={}",
1218
+ agents["claude-a"]
1219
+ );
1220
+ assert!(
1221
+ agents["claude-b"]
1222
+ .get("attribution_ambiguous")
1223
+ .is_none_or(|v| v.as_bool() != Some(true)),
1224
+ "Stage 1 fix: claude-b must NOT be flagged ambiguous; \
1225
+ state.claude-b={}",
1226
+ agents["claude-b"]
1227
+ );
1228
+ assert_eq!(
1229
+ report.ambiguous.len(),
1230
+ 0,
1231
+ "Stage 1 fix: capture report must record zero ambiguous; report={report:?}"
1232
+ );
1233
+
1234
+ let _ = std::fs::remove_file(&rollout_a);
1235
+ let _ = std::fs::remove_file(&rollout_b);
1236
+ let _ = std::fs::remove_dir(&dir);
1237
+ }
1010
1238
  }
@@ -272,9 +272,14 @@ pub fn populate_team_owner_from_env(
272
272
  "claimed_at": now_iso,
273
273
  "claimed_via": source,
274
274
  });
275
- if let Some(o) = state.as_object_mut() {
276
- o.insert("team_owner".to_string(), owner.clone());
277
- }
275
+ // Stage 3a (identity-boundary unified plan, architect direction 2026-06-23):
276
+ // route the in-memory team_owner write through the ownership repository.
277
+ // Net behaviour preserved: still writes top-level (legacy dual-write);
278
+ // 3b/3c/3d will remove the read-side projection promote, persist
279
+ // copy-back, and finally the top-level write.
280
+ let record = crate::state::ownership::OwnershipWrite::new()
281
+ .with_team_owner(owner.clone());
282
+ crate::state::ownership::write_owner(state, &key, record);
278
283
  Ok(Some(owner))
279
284
  }
280
285
 
@@ -345,10 +350,13 @@ pub fn apply_first_time_leader_binding(
345
350
  "claimed_at": now_iso,
346
351
  "claimed_via": source,
347
352
  });
348
- if let Some(o) = state.as_object_mut() {
349
- o.insert("team_owner".to_string(), owner);
350
- o.insert("leader_receiver".to_string(), receiver.clone());
351
- }
353
+ // Stage 3a: route through ownership repository.
354
+ let team_key = team_state_key(state);
355
+ let record = crate::state::ownership::OwnershipWrite::new()
356
+ .with_team_owner(owner)
357
+ .with_leader_receiver(receiver.clone())
358
+ .with_owner_epoch(0);
359
+ crate::state::ownership::write_owner(state, &team_key, record);
352
360
  json!({ "ok": true, "pane": pane_info, "warning": null, "first_time": true })
353
361
  }
354
362
 
@@ -560,6 +568,12 @@ mod tests {
560
568
 
561
569
  #[test]
562
570
  fn populate_team_owner_seed_and_skip() {
571
+ // Stage 3d (identity-boundary unified plan, architect direction
572
+ // 2026-06-23): after the top-level dual-write was dropped, owner
573
+ // lives only at `state.teams.<team_key>.team_owner`. The test now
574
+ // resolves through the ownership repository so the precedence rule
575
+ // (teams.<key> first, then legacy top-level when team_state_key
576
+ // matches) is the single source of truth.
563
577
  let e_with_pane = env(&[
564
578
  ("TEAM_AGENT_LEADER_PANE_ID", "%9"),
565
579
  ("TEAM_AGENT_LEADER_PROVIDER", "codex"),
@@ -573,14 +587,21 @@ mod tests {
573
587
  &json!({"pane_id": "%9", "provider": "codex", "machine_fingerprint": "fp",
574
588
  "leader_session_uuid": "u1", "claimed_at": "TS", "claimed_via": "autopopulate"}),
575
589
  );
576
- same_repr(&state["team_owner"], &owner);
590
+ // The canonical key is whatever team_state_key derives for the
591
+ // empty state (fallback "current" in legacy single-team flow).
592
+ let team_key = crate::state::projection::team_state_key(&state);
593
+ let repo_owner = crate::state::ownership::read_owner_value(&state, &team_key)
594
+ .expect("Stage 3d: owner readable via ownership repository");
595
+ same_repr(repo_owner, &owner);
577
596
 
578
597
  // 无 pane_id → None,不写 owner。
579
598
  let mut state2 = json!({});
580
599
  assert!(populate_team_owner_from_env(&mut state2, "x", &env(&[]), "TS").unwrap().is_none());
581
- assert!(state2.get("team_owner").is_none());
600
+ let team_key2 = crate::state::projection::team_state_key(&state2);
601
+ assert!(crate::state::ownership::read_owner_value(&state2, &team_key2).is_none());
582
602
 
583
- // 已有 team_owner(缺 uuid)→ 迁移补 uuid 后返回。
603
+ // 已有 team_owner(缺 uuid)→ 迁移补 uuid 后返回。Legacy top-level shape
604
+ // still recognized (read precedence supports the migration window).
584
605
  let mut state3 = json!({"team_owner": {"pane_id": "%1", "machine_fingerprint": "fp"}, "session_name": "s"});
585
606
  let o3 = populate_team_owner_from_env(&mut state3, "x", &env(&[("USER", "u")]), "TS").unwrap().unwrap();
586
607
  assert_eq!(o3["leader_session_uuid"].as_str().unwrap().len(), 32);
@@ -589,6 +610,10 @@ mod tests {
589
610
  #[test]
590
611
  #[serial(env)]
591
612
  fn apply_first_time_binding_success_writes_state() {
613
+ // Stage 3d (identity-boundary unified plan, architect direction
614
+ // 2026-06-23): canonical owner now lives at
615
+ // `state.teams.<team_key>.{team_owner,leader_receiver}`. Read
616
+ // through the ownership repository.
592
617
  let _env = EnvUnsetGuard::unset(&["TMUX", "TMUX_PANE"]);
593
618
  let ws = temp_ws();
594
619
  let now = "2026-06-02T09:17:59.994383+00:00";
@@ -598,13 +623,18 @@ mod tests {
598
623
  let pane = json!({"pane_current_command": "codex", "pane_current_path": ws.to_string_lossy()});
599
624
  let r = apply_first_time_leader_binding(&ws, &mut state, &mut receiver, &pane, &identity, "claim", now, |_c, _p| true);
600
625
  same_repr(&r, &json!({"ok": true, "pane": pane, "warning": null, "first_time": true}));
626
+ let team_key = crate::state::projection::team_state_key(&state);
627
+ let team_owner = crate::state::ownership::read_owner_value(&state, &team_key)
628
+ .expect("Stage 3d: team_owner reachable via repository");
601
629
  same_repr(
602
- &state["team_owner"],
630
+ team_owner,
603
631
  &json!({"pane_id": "%9", "provider": "codex", "machine_fingerprint": "fpX",
604
632
  "leader_session_uuid": "uuidX", "owner_epoch": 0, "claimed_at": now, "claimed_via": "claim"}),
605
633
  );
634
+ // leader_receiver lives at the same canonical location.
635
+ let leader_receiver = state["teams"][&team_key]["leader_receiver"].clone();
606
636
  same_repr(
607
- &state["leader_receiver"],
637
+ &leader_receiver,
608
638
  &json!({"pane_id": "%9", "provider": "codex", "leader_session_uuid": "uuidX",
609
639
  "machine_fingerprint": "fpX", "owner_epoch": 0}),
610
640
  );