@team-agent/installer 0.4.0 → 0.4.2

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 (45) 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/coordinator/tick.rs +86 -16
  19. package/crates/team-agent/src/diagnose/comms.rs +10 -2
  20. package/crates/team-agent/src/leader/lease.rs +98 -19
  21. package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
  22. package/crates/team-agent/src/leader/rediscover.rs +16 -7
  23. package/crates/team-agent/src/leader/start.rs +25 -12
  24. package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
  25. package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
  26. package/crates/team-agent/src/lifecycle/launch.rs +116 -100
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
  30. package/crates/team-agent/src/lifecycle/types.rs +1 -1
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
  32. package/crates/team-agent/src/messaging/send.rs +21 -4
  33. package/crates/team-agent/src/provider/adapter.rs +22 -0
  34. package/crates/team-agent/src/provider/session/capture.rs +228 -0
  35. package/crates/team-agent/src/state/identity.rs +42 -12
  36. package/crates/team-agent/src/state/identity_keys.rs +134 -0
  37. package/crates/team-agent/src/state/mod.rs +8 -0
  38. package/crates/team-agent/src/state/owner_gate.rs +11 -1
  39. package/crates/team-agent/src/state/ownership.rs +556 -0
  40. package/crates/team-agent/src/state/paths.rs +358 -0
  41. package/crates/team-agent/src/state/persist.rs +43 -5
  42. package/crates/team-agent/src/state/projection.rs +13 -5
  43. package/crates/team-agent/src/tmux_backend.rs +12 -0
  44. package/package.json +4 -4
  45. package/skills/team-agent/SKILL.md +3 -3
@@ -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,24 @@ 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
+ if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
1076
+ let positive_only: Vec<CapturedSessionCandidate> = out
1077
+ .iter()
1078
+ .filter(|candidate| candidate.positive_agent_id_match || candidate.agent_path_match)
1079
+ .cloned()
1080
+ .collect();
1081
+ return Ok(positive_only);
1082
+ }
1065
1083
  }
1066
1084
  // E6 层1·B(主路径,交互式现实):cwd 匹配但盘上有多个 sibling transcript(claude 自生成,
1067
1085
  // 不采用预定 UUID)→ 用 spawn 时间窗唯一选:只留 mtime >= spawned_at 的候选,打破歧义。
@@ -1086,6 +1104,10 @@ fn scan_session_candidates_once(
1086
1104
  }
1087
1105
  }
1088
1106
  }
1107
+ // Non-Claude / non-strict providers with an expected id but no exact
1108
+ // match: order expected-first so the allocator's `unique_available_candidate`
1109
+ // sees the deterministically preferred candidate. Claude/ClaudeCode took
1110
+ // the strict positive-only return above and never reaches here.
1089
1111
  if let Some(expected) = context.expected_session_id.as_ref() {
1090
1112
  out.sort_by_key(|candidate| {
1091
1113
  candidate
@@ -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
  );
@@ -0,0 +1,134 @@
1
+ //! Stage 0 of the identity-boundary unified plan (architect direction
2
+ //! 2026-06-23): `SessionAttributionKey`.
3
+ //!
4
+ //! Pure additive: nobody constructs this type yet. Stage 1 (Claude strict
5
+ //! capture) starts populating it inside `CaptureSessionContext`; Stage 4
6
+ //! (CLI/DB scope gate) starts using it as the DB/event filter key.
7
+ //!
8
+ //! Why a struct, not a tuple:
9
+ //! - The four fields are not interchangeable: `provider` selects the
10
+ //! tombstone/claimed-session table, `team_key` is the multi-team boundary,
11
+ //! `agent_id` is the worker identity inside a team, `session_id` is the
12
+ //! provider-issued transcript identity.
13
+ //! - The architect's verdict §1 names this exact shape as the "shared
14
+ //! identity primitive" that owner / capture / multi-team all need so they
15
+ //! don't drift into per-module ad-hoc keys.
16
+ //!
17
+ //! Single-team behaviour: `team_key` is still populated (from
18
+ //! `state::projection::team_state_key` or the same selector path Stage 0
19
+ //! introduces). The key is invariant — capture/owner/CLI all read it from
20
+ //! the same source.
21
+
22
+ use crate::model::enums::Provider;
23
+
24
+ /// The canonical identity of a "this worker owns this provider transcript"
25
+ /// claim. Stage 1 + Stage 4 will both store and compare via this struct.
26
+ ///
27
+ /// All four fields are non-empty by construction (the constructor refuses
28
+ /// empty `team_key`, `agent_id`, or `session_id` — those would collapse
29
+ /// distinct sessions into one).
30
+ #[derive(Debug, Clone, PartialEq, Eq, Hash)]
31
+ pub struct SessionAttributionKey {
32
+ provider: Provider,
33
+ team_key: String,
34
+ agent_id: String,
35
+ session_id: String,
36
+ }
37
+
38
+ impl SessionAttributionKey {
39
+ pub fn new(
40
+ provider: Provider,
41
+ team_key: impl Into<String>,
42
+ agent_id: impl Into<String>,
43
+ session_id: impl Into<String>,
44
+ ) -> Option<Self> {
45
+ let team_key = team_key.into();
46
+ let agent_id = agent_id.into();
47
+ let session_id = session_id.into();
48
+ if team_key.is_empty() || agent_id.is_empty() || session_id.is_empty() {
49
+ return None;
50
+ }
51
+ Some(Self {
52
+ provider,
53
+ team_key,
54
+ agent_id,
55
+ session_id,
56
+ })
57
+ }
58
+
59
+ pub fn provider(&self) -> Provider {
60
+ self.provider
61
+ }
62
+
63
+ pub fn team_key(&self) -> &str {
64
+ &self.team_key
65
+ }
66
+
67
+ pub fn agent_id(&self) -> &str {
68
+ &self.agent_id
69
+ }
70
+
71
+ pub fn session_id(&self) -> &str {
72
+ &self.session_id
73
+ }
74
+ }
75
+
76
+ #[cfg(test)]
77
+ mod tests {
78
+ use super::*;
79
+
80
+ #[test]
81
+ fn refuses_empty_team_key() {
82
+ assert!(
83
+ SessionAttributionKey::new(Provider::Claude, "", "agent_a", "sess_1").is_none()
84
+ );
85
+ }
86
+
87
+ #[test]
88
+ fn refuses_empty_agent_id() {
89
+ assert!(
90
+ SessionAttributionKey::new(Provider::Claude, "alpha", "", "sess_1").is_none()
91
+ );
92
+ }
93
+
94
+ #[test]
95
+ fn refuses_empty_session_id() {
96
+ assert!(
97
+ SessionAttributionKey::new(Provider::Claude, "alpha", "agent_a", "").is_none()
98
+ );
99
+ }
100
+
101
+ #[test]
102
+ fn accepts_well_formed_key_and_preserves_fields() {
103
+ let key = SessionAttributionKey::new(
104
+ Provider::Claude,
105
+ "alpha",
106
+ "reviewer",
107
+ "36dd1d1a-2766-4636-856c-03f14a4bb803",
108
+ )
109
+ .unwrap();
110
+ assert_eq!(key.provider(), Provider::Claude);
111
+ assert_eq!(key.team_key(), "alpha");
112
+ assert_eq!(key.agent_id(), "reviewer");
113
+ assert_eq!(key.session_id(), "36dd1d1a-2766-4636-856c-03f14a4bb803");
114
+ }
115
+
116
+ /// Same provider + agent_id + session_id but different team_key → distinct
117
+ /// identity. This is the multi-team isolation invariant the unified plan
118
+ /// requires (architect §1 cross point: tombstone/claimed by full
119
+ /// (provider, team_key, agent_id, session_id) tuple).
120
+ #[test]
121
+ fn different_team_keys_produce_different_keys() {
122
+ let alpha = SessionAttributionKey::new(Provider::Claude, "alpha", "reviewer", "s1").unwrap();
123
+ let beta = SessionAttributionKey::new(Provider::Claude, "beta", "reviewer", "s1").unwrap();
124
+ assert_ne!(alpha, beta);
125
+ }
126
+
127
+ /// Same team but different agent_id → distinct (per-worker isolation).
128
+ #[test]
129
+ fn different_agent_ids_produce_different_keys() {
130
+ let reviewer = SessionAttributionKey::new(Provider::Claude, "alpha", "reviewer", "s1").unwrap();
131
+ let frontend = SessionAttributionKey::new(Provider::Claude, "alpha", "frontend", "s1").unwrap();
132
+ assert_ne!(reviewer, frontend);
133
+ }
134
+ }
@@ -15,7 +15,15 @@
15
15
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
16
16
 
17
17
  pub mod identity;
18
+ // Stage 0 of identity-boundary unified plan (architect direction 2026-06-23):
19
+ // shared identity primitives. Pure additive — Stage 1+ consume these.
20
+ pub mod identity_keys;
18
21
  pub mod owner_gate;
22
+ // Stage 2 of identity-boundary unified plan: single read entry point for
23
+ // `team_owner` lookups. Stage 3 will migrate writers; Stage 5 will swap
24
+ // the data source to per-team canonical state without touching call sites.
25
+ pub mod ownership;
26
+ pub mod paths;
19
27
  pub mod persist;
20
28
  pub mod projection;
21
29
  pub mod selector;
@@ -39,13 +39,23 @@ pub struct CallerIdentity {
39
39
 
40
40
  /// `check_team_owner`(`state.py:366`)的纯判定版:caller 身份 + 是否有 TEAM_AGENT_ID + liveness 注入。
41
41
  /// 返回 `None`=允许(own / 无 owner / 死 owner pane 可接管);`Some(dict)`=拒绝(team_owner_mismatch)。
42
+ ///
43
+ /// Stage 2 (identity-boundary unified plan, architect direction 2026-06-23):
44
+ /// the owner is now looked up through `state::ownership::read_owner_value`
45
+ /// instead of `state::projection::read_owner` directly. The two are
46
+ /// equivalent today for the gate's `None` (empty team_key) path — both
47
+ /// resolve to top-level `state.team_owner` — but routing through the
48
+ /// repository means Stage 5 can swap the data source (per-team canonical
49
+ /// `state.json`) without touching this gate. The empty-team-key argument
50
+ /// preserves the pre-Stage-2 shape: gate callers feed an
51
+ /// already-team-projected state and expect top-level reads.
42
52
  pub fn check_team_owner(
43
53
  state: &Value,
44
54
  caller: &CallerIdentity,
45
55
  has_team_agent_id: bool,
46
56
  liveness: &dyn PaneLivenessProbe,
47
57
  ) -> Option<Value> {
48
- let owner = crate::state::projection::read_owner(state, None)?;
58
+ let owner = crate::state::ownership::read_owner_value(state, "")?;
49
59
  if !owner.is_object() || owner.as_object().is_none_or(serde_json::Map::is_empty) {
50
60
  return None;
51
61
  }