@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +82 -22
- package/crates/team-agent/src/cli/diagnose.rs +6 -2
- package/crates/team-agent/src/cli/emit.rs +301 -38
- package/crates/team-agent/src/cli/mod.rs +7 -14
- package/crates/team-agent/src/cli/status_port.rs +12 -1
- package/crates/team-agent/src/cli/tests/base.rs +6 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
- package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +35 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
- package/crates/team-agent/src/coordinator/tick.rs +86 -16
- package/crates/team-agent/src/diagnose/comms.rs +10 -2
- package/crates/team-agent/src/leader/lease.rs +98 -19
- package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
- package/crates/team-agent/src/leader/rediscover.rs +16 -7
- package/crates/team-agent/src/leader/start.rs +25 -12
- package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
- package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
- package/crates/team-agent/src/lifecycle/launch.rs +116 -100
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
- package/crates/team-agent/src/lifecycle/types.rs +1 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
- package/crates/team-agent/src/messaging/send.rs +21 -4
- package/crates/team-agent/src/provider/adapter.rs +22 -0
- package/crates/team-agent/src/provider/session/capture.rs +228 -0
- package/crates/team-agent/src/state/identity.rs +42 -12
- package/crates/team-agent/src/state/identity_keys.rs +134 -0
- package/crates/team-agent/src/state/mod.rs +8 -0
- package/crates/team-agent/src/state/owner_gate.rs +11 -1
- package/crates/team-agent/src/state/ownership.rs +556 -0
- package/crates/team-agent/src/state/paths.rs +358 -0
- package/crates/team-agent/src/state/persist.rs +43 -5
- package/crates/team-agent/src/state/projection.rs +13 -5
- package/crates/team-agent/src/tmux_backend.rs +12 -0
- package/package.json +4 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
&
|
|
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::
|
|
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
|
}
|