@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.
- 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/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 +126 -100
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +40 -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 +72 -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
|
@@ -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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
240
|
-
.
|
|
241
|
-
.
|
|
242
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
);
|