@team-agent/installer 0.4.5 → 0.4.6

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.
@@ -21,6 +21,11 @@ pub struct CapturePassReport {
21
21
  pub ambiguous: Vec<AmbiguousSessionCapture>,
22
22
  pub capture_failures: Vec<SessionCaptureFailure>,
23
23
  pub candidate_count_by_agent: BTreeMap<String, usize>,
24
+ /// 0.4.6 Stage 3: agents that transitioned into the
25
+ /// `transcript_missing` capture_state during this pass. The caller
26
+ /// (coordinator tick) emits a throttled
27
+ /// `provider.session.transcript_missing` event for each entry.
28
+ pub transcript_missing: Vec<TranscriptMissing>,
24
29
  }
25
30
 
26
31
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -35,6 +40,19 @@ pub struct AmbiguousSessionCapture {
35
40
  pub spawn_cwd: String,
36
41
  }
37
42
 
43
+ /// 0.4.6 Stage 3: information emitted when a pending agent transitions
44
+ /// into the `transcript_missing` capture_state. Carries the diagnostic
45
+ /// fields the caller uses to write the
46
+ /// `provider.session.transcript_missing` event.
47
+ #[derive(Debug, Clone, PartialEq, Eq)]
48
+ pub struct TranscriptMissing {
49
+ pub agent_id: String,
50
+ pub spawn_cwd: String,
51
+ pub spawn_epoch: u64,
52
+ pub expected_session_id: Option<String>,
53
+ pub candidate_count: usize,
54
+ }
55
+
38
56
  #[derive(Debug, Clone, PartialEq, Eq)]
39
57
  pub struct SessionConvergence {
40
58
  pub converged: bool,
@@ -173,7 +191,7 @@ where
173
191
  .iter()
174
192
  .map(|item| item.agent_id.clone())
175
193
  .collect::<BTreeSet<_>>();
176
- let mut claimed = claimed_provider_session_keys(agent_map, &pending_ids);
194
+ let mut claimed = claimed_provider_session_keys(state, agent_map, &pending_ids);
177
195
  let (assignments, ambiguous_ids) =
178
196
  allocate_session_candidates(&pending, &candidates_by_agent, &mut claimed);
179
197
 
@@ -220,17 +238,27 @@ where
220
238
  "attribution_ambiguous".to_string(),
221
239
  serde_json::json!(true),
222
240
  );
223
- agent_obj.insert(
224
- "captured_at".to_string(),
225
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
226
- );
241
+ // 0.4.6 tuple-atomic contract (audit:93): do NOT write
242
+ // `captured_at` for ambiguity diagnostics. `captured_at`
243
+ // is part of the authoritative tuple and may only be
244
+ // set together with `session_id + rollout_path +
245
+ // captured_via`. Overloading it as both capture
246
+ // timestamp and ambiguity timestamp created false
247
+ // "looks captured" rows. Ambiguity diagnostics live in
248
+ // the `attribution_ambiguous` flag + events; persist
249
+ // event log for the timestamp.
227
250
  report.changed = true;
228
251
  }
229
252
  continue;
230
253
  }
231
- apply_captured_session(agent_obj, &candidate.captured);
232
- report.changed = true;
233
- report.assigned.push(item.agent_id);
254
+ // 0.4.6 tuple-atomic contract: apply_captured_session refuses
255
+ // partial candidates (no session_id or no rollout_path). When
256
+ // it returns false, the row stays unchanged; capture re-runs
257
+ // on the next coordinator tick.
258
+ if apply_captured_session(agent_obj, &candidate.captured) {
259
+ report.changed = true;
260
+ report.assigned.push(item.agent_id);
261
+ }
234
262
  continue;
235
263
  }
236
264
  if ambiguous_ids.contains(&item.agent_id) {
@@ -241,16 +269,151 @@ where
241
269
  if finalize_ambiguous {
242
270
  agent_obj.insert("attribution_ambiguous".to_string(), serde_json::json!(true));
243
271
  agent_obj.insert(
244
- "captured_at".to_string(),
245
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
272
+ "capture_state".to_string(),
273
+ serde_json::json!("attribution_ambiguous"),
246
274
  );
275
+ // 0.4.6 tuple-atomic contract: see comment above. Removed
276
+ // `captured_at` ambiguity write.
247
277
  report.changed = true;
248
278
  }
279
+ continue;
280
+ }
281
+ // 0.4.6 Stage 3: transcript-ready handshake. For pending agents that
282
+ // are NOT ambiguous and were NOT captured this pass, classify into:
283
+ // * `captured` — session_id + rollout_path already on row
284
+ // * `transcript_missing` — trigger event has fired (report_result /
285
+ // first_send_at / pane_output_advanced) AND elapsed > threshold
286
+ // AND zero candidates / no backing
287
+ // * `waiting_for_transcript` — still in grace period
288
+ // The state field surfaces in status/diagnose without changing the
289
+ // authoritative tuple contract (we never write session_id without
290
+ // backing). Throttled event emission is done by the caller (tick).
291
+ let has_session = agent_obj
292
+ .get("session_id")
293
+ .and_then(Value::as_str)
294
+ .is_some_and(|s| !s.is_empty())
295
+ && agent_obj
296
+ .get("rollout_path")
297
+ .and_then(Value::as_str)
298
+ .is_some_and(|s| !s.is_empty());
299
+ if has_session {
300
+ // Captured already — fix state field if drifted.
301
+ if agent_obj
302
+ .get("capture_state")
303
+ .and_then(Value::as_str)
304
+ != Some("captured")
305
+ {
306
+ agent_obj.insert("capture_state".to_string(), serde_json::json!("captured"));
307
+ report.changed = true;
308
+ }
309
+ continue;
310
+ }
311
+ let candidate_count = report
312
+ .candidate_count_by_agent
313
+ .get(&item.agent_id)
314
+ .copied()
315
+ .unwrap_or(0);
316
+ let next_state = classify_pending_capture_state(agent_obj, candidate_count);
317
+ let prev_state = agent_obj
318
+ .get("capture_state")
319
+ .and_then(Value::as_str)
320
+ .map(str::to_string);
321
+ if prev_state.as_deref() != Some(next_state) {
322
+ agent_obj.insert(
323
+ "capture_state".to_string(),
324
+ serde_json::json!(next_state),
325
+ );
326
+ report.changed = true;
327
+ if next_state == "transcript_missing" {
328
+ report.transcript_missing.push(TranscriptMissing {
329
+ agent_id: item.agent_id.clone(),
330
+ spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
331
+ spawn_epoch: agent_obj
332
+ .get("spawn_epoch")
333
+ .and_then(Value::as_u64)
334
+ .unwrap_or(0),
335
+ expected_session_id: item
336
+ .context
337
+ .expected_session_id
338
+ .as_ref()
339
+ .map(|s| s.as_str().to_string()),
340
+ candidate_count,
341
+ });
342
+ }
249
343
  }
250
344
  }
251
345
  Ok(report)
252
346
  }
253
347
 
348
+ /// 0.4.6 Stage 3: classify a pending agent's capture state into one of:
349
+ /// * `transcript_missing` — has had a strong trigger event AND elapsed
350
+ /// beyond grace threshold AND still no backing
351
+ /// * `waiting_for_transcript` — still in grace period or no trigger yet
352
+ ///
353
+ /// Trigger events (CR M2): any of `first_send_at`, `last_result_at`, or a
354
+ /// recent `last_pane_output_at` indicates the worker has been interactively
355
+ /// used / done something. Without ANY trigger, we stay in waiting state
356
+ /// even past the grace window (silent worker is not a failure to capture).
357
+ ///
358
+ /// Threshold: 30 seconds from spawned_at OR 15 seconds from the first
359
+ /// trigger event, whichever is later. This avoids spurious
360
+ /// transcript_missing on slow first-paint while catching the
361
+ /// release-engineer scenario (report_result fired but no jsonl).
362
+ fn classify_pending_capture_state(
363
+ agent_obj: &serde_json::Map<String, Value>,
364
+ candidate_count: usize,
365
+ ) -> &'static str {
366
+ // Already-captured agents don't reach here (caller skips). We only
367
+ // classify pending agents.
368
+ let now = chrono::Utc::now();
369
+ let spawned_at = agent_obj
370
+ .get("spawned_at")
371
+ .and_then(Value::as_str)
372
+ .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
373
+ .map(|dt| dt.with_timezone(&chrono::Utc));
374
+ let elapsed_since_spawn = spawned_at
375
+ .map(|t| now.signed_duration_since(t).num_seconds())
376
+ .unwrap_or(0);
377
+ // Trigger event detection — any strong signal that the worker has
378
+ // actually been used. Pre-conditions (CR M2 in architect doc).
379
+ let has_trigger = ["first_send_at", "last_result_at", "last_pane_output_at"]
380
+ .iter()
381
+ .any(|key| {
382
+ agent_obj
383
+ .get(*key)
384
+ .and_then(Value::as_str)
385
+ .is_some_and(|s| !s.is_empty())
386
+ });
387
+ // Earliest trigger timestamp for the secondary threshold.
388
+ let earliest_trigger = ["first_send_at", "last_result_at", "last_pane_output_at"]
389
+ .iter()
390
+ .filter_map(|key| {
391
+ agent_obj
392
+ .get(*key)
393
+ .and_then(Value::as_str)
394
+ .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
395
+ .map(|dt| dt.with_timezone(&chrono::Utc))
396
+ })
397
+ .min();
398
+ let elapsed_since_trigger = earliest_trigger
399
+ .map(|t| now.signed_duration_since(t).num_seconds())
400
+ .unwrap_or(0);
401
+ const SPAWN_GRACE_SECS: i64 = 30;
402
+ const TRIGGER_GRACE_SECS: i64 = 15;
403
+ let past_spawn_grace = elapsed_since_spawn >= SPAWN_GRACE_SECS;
404
+ let past_trigger_grace = has_trigger && elapsed_since_trigger >= TRIGGER_GRACE_SECS;
405
+ // transcript_missing requires BOTH: a trigger occurred AND we've
406
+ // waited past the spawn grace (so we're not blamed for slow startup).
407
+ // candidate_count == 0 is the "no backing" signal; non-zero
408
+ // candidates that didn't satisfy assignment go through the ambiguous
409
+ // path above and don't reach here.
410
+ if has_trigger && past_spawn_grace && past_trigger_grace && candidate_count == 0 {
411
+ "transcript_missing"
412
+ } else {
413
+ "waiting_for_transcript"
414
+ }
415
+ }
416
+
254
417
  pub fn incomplete_resumable_agent_ids(state: &Value) -> Vec<String> {
255
418
  let Some(agents) = state.get("agents").and_then(Value::as_object) else {
256
419
  return Vec::new();
@@ -281,6 +444,13 @@ pub fn recover_resume_session_from_events(
281
444
  auth_mode: crate::provider::AuthMode,
282
445
  exclude_session_ids: &BTreeSet<String>,
283
446
  ) -> Result<Option<Value>, ProviderError> {
447
+ // E57 postflight: read the agent's provider once; needed for the Claude
448
+ // leader-marker filter below. Falls back to None on non-string / missing,
449
+ // in which case the marker filter is skipped (only meaningful for Claude).
450
+ let provider = previous
451
+ .get("provider")
452
+ .and_then(Value::as_str)
453
+ .and_then(parse_provider);
284
454
  let events = crate::event_log::EventLog::new(workspace)
285
455
  .tail(0)
286
456
  .map_err(|e| ProviderError::Io(e.to_string()))?;
@@ -310,6 +480,20 @@ pub fn recover_resume_session_from_events(
310
480
  let Some(rollout_path) = event_rollout_path(event).filter(|path| path.exists()) else {
311
481
  continue;
312
482
  };
483
+ // E57 postflight (lane-046-capture-gap): the capture allocator already
484
+ // refuses Claude leader transcripts (P0 d39b5104,
485
+ // claude_records_have_leader_marker in provider/adapter.rs). Event-log
486
+ // repair must apply the SAME filter — otherwise a stale `session.captured`
487
+ // event from a pre-fix run (or from a window where the allocator was
488
+ // bypassed) still pulls the 590MB leader transcript onto a worker on the
489
+ // next restart (Mac mini repro: session_id=ea059b82 reassigned to
490
+ // release-engineer via captured_via=event_log_repair). The check is
491
+ // a no-op for non-Claude providers.
492
+ if let Some(p) = provider {
493
+ if crate::provider::adapter::rollout_path_has_claude_leader_marker(p, &rollout_path) {
494
+ continue;
495
+ }
496
+ }
313
497
  let session = SessionId::new(session_id.to_string());
314
498
  if !adapter.session_is_resumable(Some(&session), auth_mode)? {
315
499
  continue;
@@ -321,14 +505,22 @@ pub fn recover_resume_session_from_events(
321
505
  let Some(obj) = repaired.as_object_mut() else {
322
506
  continue;
323
507
  };
508
+ // 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 113):
509
+ // repair must yield a COMPLETE tuple. session_id + rollout_path are
510
+ // already validated above; ensure captured_at + captured_via are
511
+ // also always written (fall back to now() if the event has no ts).
324
512
  obj.insert("session_id".to_string(), serde_json::json!(session_id));
325
513
  obj.insert(
326
514
  "rollout_path".to_string(),
327
515
  serde_json::json!(rollout_path.to_string_lossy().to_string()),
328
516
  );
329
- if let Some(ts) = event.get("ts").and_then(Value::as_str).filter(|ts| !ts.is_empty()) {
330
- obj.insert("captured_at".to_string(), serde_json::json!(ts));
331
- }
517
+ let captured_at = event
518
+ .get("ts")
519
+ .and_then(Value::as_str)
520
+ .filter(|ts| !ts.is_empty())
521
+ .map(str::to_string)
522
+ .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
523
+ obj.insert("captured_at".to_string(), serde_json::json!(captured_at));
332
524
  obj.insert(
333
525
  "captured_via".to_string(),
334
526
  serde_json::json!("event_log_repair"),
@@ -435,6 +627,13 @@ where
435
627
  // — that would trigger the Stage 1 mismatch guard against Codex's
436
628
  // real session_meta.payload.id and permanently reject the correct
437
629
  // transcript. Codex capture anchors purely on (cwd, spawned_at).
630
+ // 0.4.7 (B1 verified, partial-resume revert of 9feafc31):
631
+ // Claude --session-id was restored in adapter.rs build_command_plan
632
+ // because Claude ≥ 2.1.185 DOES honour framework-supplied session
633
+ // id and DOES create a transcript at that id. So `_pending_session_id`
634
+ // is once again valid for Claude — re-enable the Stage 1 pre-pass
635
+ // for Claude/ClaudeCode. Codex remains excluded (Codex CLI ignores
636
+ // --session-id, capture must anchor purely on cwd+spawned_at).
438
637
  expected_session_id: if matches!(provider, Provider::Codex) {
439
638
  None
440
639
  } else {
@@ -493,6 +692,21 @@ fn agent_session_complete(agent: &Value) -> bool {
493
692
  Some(path) => path,
494
693
  None => return false,
495
694
  };
695
+ // 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 110):
696
+ // a "complete" agent session requires the FULL authoritative tuple —
697
+ // session_id + rollout_path + captured_at + captured_via. Without these
698
+ // last two, a row is a partial tuple and capture must re-run.
699
+ let captured_at_ok = agent
700
+ .get("captured_at")
701
+ .and_then(Value::as_str)
702
+ .is_some_and(|ts| !ts.is_empty());
703
+ let captured_via_ok = agent
704
+ .get("captured_via")
705
+ .and_then(Value::as_str)
706
+ .is_some_and(|via| !via.is_empty());
707
+ if !captured_at_ok || !captured_via_ok {
708
+ return false;
709
+ }
496
710
  let path = std::path::Path::new(rollout_path);
497
711
  if !path.exists() {
498
712
  return false;
@@ -619,6 +833,27 @@ fn allocate_session_candidates(
619
833
  if assignments.contains_key(&item.agent_id) {
620
834
  continue;
621
835
  }
836
+ // P0 (lane-046-capture-gap): Claude/ClaudeCode with no
837
+ // expected_session_id (natural fresh — Stage 1 expected-id miss
838
+ // guard cannot fire) must NOT accept the weak `Any` fallback.
839
+ // Without this guard, a same-cwd leader transcript (no positive
840
+ // agent-id match, no path match) becomes the sole candidate via
841
+ // the time window and gets attributed to a worker. Only
842
+ // positive-agent-id / path-agent-id can authoritatively bind a
843
+ // Claude no-expected worker session.
844
+ let claude_no_expected = matches!(
845
+ item.provider,
846
+ Provider::Claude | Provider::ClaudeCode
847
+ ) && item.context.expected_session_id.is_none();
848
+ if claude_no_expected {
849
+ if candidates_by_agent
850
+ .get(&item.agent_id)
851
+ .is_some_and(|candidates| !candidates.is_empty())
852
+ {
853
+ ambiguous.insert(item.agent_id.clone());
854
+ }
855
+ continue;
856
+ }
622
857
  match unique_available_candidate(
623
858
  candidates_by_agent.get(&item.agent_id),
624
859
  claimed,
@@ -647,9 +882,16 @@ fn allocate_global_one_to_one(
647
882
  claimed: &mut BTreeSet<String>,
648
883
  assignments: &mut BTreeMap<String, CapturedSessionCandidate>,
649
884
  ) {
885
+ // P0 (lane-046-capture-gap): exclude Claude no-expected agents from the
886
+ // global one-to-one weak allocator. They must use PositiveAgentId or
887
+ // PathAgentId only — see allocate_session_candidates Any-block.
650
888
  let remaining_agents = pending
651
889
  .iter()
652
890
  .filter(|item| !assignments.contains_key(&item.agent_id))
891
+ .filter(|item| {
892
+ !(matches!(item.provider, Provider::Claude | Provider::ClaudeCode)
893
+ && item.context.expected_session_id.is_none())
894
+ })
653
895
  .map(|item| item.agent_id.clone())
654
896
  .collect::<Vec<_>>();
655
897
  if remaining_agents.is_empty() {
@@ -722,16 +964,31 @@ fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
722
964
  .join("|")
723
965
  }
724
966
 
725
- fn apply_captured_session(agent_obj: &mut serde_json::Map<String, Value>, captured: &CapturedSession) {
726
- if let Some(session_id) = &captured.session_id {
727
- agent_obj.insert("session_id".to_string(), serde_json::json!(session_id.as_str()));
728
- }
729
- if let Some(rollout_path) = &captured.rollout_path {
730
- agent_obj.insert(
731
- "rollout_path".to_string(),
732
- serde_json::json!(rollout_path.as_path().to_string_lossy()),
733
- );
734
- }
967
+ /// 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 111): writes
968
+ /// the authoritative session tuple if and only if the candidate carries
969
+ /// BOTH `session_id` and `rollout_path`. A partial candidate (e.g.
970
+ /// scanner returned ambiguous attribution with no session_id) MUST NOT
971
+ /// stamp `captured_at` / `captured_via` onto the row, because that
972
+ /// produces a partial tuple that downstream readers treat as authority.
973
+ /// Returns true iff the full tuple was written.
974
+ fn apply_captured_session(
975
+ agent_obj: &mut serde_json::Map<String, Value>,
976
+ captured: &CapturedSession,
977
+ ) -> bool {
978
+ let Some(session_id) = captured.session_id.as_ref() else {
979
+ return false;
980
+ };
981
+ let Some(rollout_path) = captured.rollout_path.as_ref() else {
982
+ return false;
983
+ };
984
+ agent_obj.insert(
985
+ "session_id".to_string(),
986
+ serde_json::json!(session_id.as_str()),
987
+ );
988
+ agent_obj.insert(
989
+ "rollout_path".to_string(),
990
+ serde_json::json!(rollout_path.as_path().to_string_lossy()),
991
+ );
735
992
  agent_obj.insert(
736
993
  "captured_at".to_string(),
737
994
  serde_json::json!(chrono::Utc::now().to_rfc3339()),
@@ -745,33 +1002,64 @@ fn apply_captured_session(agent_obj: &mut serde_json::Map<String, Value>, captur
745
1002
  serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
746
1003
  );
747
1004
  agent_obj.remove("attribution_ambiguous");
1005
+ true
748
1006
  }
749
1007
 
750
1008
  fn claimed_provider_session_keys(
1009
+ state: &Value,
751
1010
  agents: &serde_json::Map<String, Value>,
752
1011
  pending_ids: &BTreeSet<String>,
753
1012
  ) -> BTreeSet<String> {
754
1013
  let mut keys = BTreeSet::new();
1014
+ // 1. Non-pending worker sessions (existing behaviour).
755
1015
  for (agent_id, agent) in agents {
756
1016
  if pending_ids.contains(agent_id) {
757
1017
  continue;
758
1018
  }
759
- if let Some(session_id) = agent
760
- .get("session_id")
1019
+ push_provider_session_keys(&mut keys, agent);
1020
+ }
1021
+ // 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
1022
+ // own provider transcript must never be attributed to a worker. Scan
1023
+ // state.leader_receiver, state.team_owner, and the same fields under
1024
+ // state.teams.<key>. Cover all known provider session field names so
1025
+ // a present transcript path/id excludes that session from worker
1026
+ // allocation.
1027
+ push_leader_provider_session_keys(&mut keys, state);
1028
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
1029
+ for team_state in teams.values() {
1030
+ push_leader_provider_session_keys(&mut keys, team_state);
1031
+ }
1032
+ }
1033
+ keys
1034
+ }
1035
+
1036
+ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1037
+ for field in ["session_id", "provider_session_id"] {
1038
+ if let Some(session_id) = value
1039
+ .get(field)
761
1040
  .and_then(Value::as_str)
762
1041
  .filter(|s| !s.is_empty())
763
1042
  {
764
1043
  keys.insert(format!("session:{session_id}"));
765
1044
  }
766
- if let Some(rollout_path) = agent
767
- .get("rollout_path")
1045
+ }
1046
+ for field in ["rollout_path", "transcript_path"] {
1047
+ if let Some(path) = value
1048
+ .get(field)
768
1049
  .and_then(Value::as_str)
769
1050
  .filter(|s| !s.is_empty())
770
1051
  {
771
- keys.insert(format!("rollout:{rollout_path}"));
1052
+ keys.insert(format!("rollout:{path}"));
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ fn push_leader_provider_session_keys(keys: &mut BTreeSet<String>, scope: &Value) {
1058
+ for anchor in ["leader_receiver", "team_owner"] {
1059
+ if let Some(node) = scope.get(anchor) {
1060
+ push_provider_session_keys(keys, node);
772
1061
  }
773
1062
  }
774
- keys
775
1063
  }
776
1064
 
777
1065
  fn captured_provider_session_keys(captured: &CapturedSession) -> BTreeSet<String> {
@@ -1018,6 +1306,171 @@ pub(crate) mod test_support {
1018
1306
  mod u1_tests {
1019
1307
  use super::*;
1020
1308
 
1309
+ use crate::provider::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
1310
+ use std::path::PathBuf;
1311
+
1312
+ fn leader_like_candidate(session_id: &str, path: &str) -> CapturedSessionCandidate {
1313
+ CapturedSessionCandidate {
1314
+ captured: CapturedSession {
1315
+ session_id: Some(SessionId::new(session_id)),
1316
+ rollout_path: Some(RolloutPath::new(PathBuf::from(path))),
1317
+ captured_via: CaptureVia::FsWatch,
1318
+ attribution_confidence: Confidence::High,
1319
+ spawn_cwd: PathBuf::from("/tmp/u1-cwd"),
1320
+ },
1321
+ positive_agent_id_match: false,
1322
+ agent_path_match: false,
1323
+ }
1324
+ }
1325
+
1326
+ fn worker_owned_candidate(session_id: &str, path: &str) -> CapturedSessionCandidate {
1327
+ CapturedSessionCandidate {
1328
+ captured: CapturedSession {
1329
+ session_id: Some(SessionId::new(session_id)),
1330
+ rollout_path: Some(RolloutPath::new(PathBuf::from(path))),
1331
+ captured_via: CaptureVia::FsWatch,
1332
+ attribution_confidence: Confidence::High,
1333
+ spawn_cwd: PathBuf::from("/tmp/u1-cwd"),
1334
+ },
1335
+ positive_agent_id_match: true,
1336
+ agent_path_match: false,
1337
+ }
1338
+ }
1339
+
1340
+ /// P0 RED-1 (lane-046-capture-gap): a Claude worker with
1341
+ /// expected_session_id=None must NOT capture a candidate that has neither
1342
+ /// positive_agent_id_match nor agent_path_match — even when it is the
1343
+ /// only candidate (the leader-transcript-as-only-candidate failure mode
1344
+ /// from the macmini repro). Must end up ambiguous, not assigned.
1345
+ #[test]
1346
+ fn claude_no_expected_single_leader_candidate_is_not_assigned() {
1347
+ let mut state = serde_json::json!({
1348
+ "agents": {
1349
+ "release_engineer": {
1350
+ "provider": "claude",
1351
+ "status": "running",
1352
+ "spawn_cwd": "/tmp/u1-cwd"
1353
+ }
1354
+ }
1355
+ });
1356
+ let mut canned = BTreeMap::new();
1357
+ canned.insert(
1358
+ "release_engineer".to_string(),
1359
+ vec![leader_like_candidate(
1360
+ "ea059b82-c53e-4654-9590-9f3e6d46f0ca",
1361
+ "/Users/alauda/.claude/projects/-Users-alauda-team/ea059b82.jsonl",
1362
+ )],
1363
+ );
1364
+ let canned_for_adapter = canned.clone();
1365
+ let mut adapter_for = move |provider| {
1366
+ Box::new(
1367
+ test_support::CaptureCandidatesAdapter::new(provider, None, "")
1368
+ .with_candidates(canned_for_adapter.clone()),
1369
+ ) as Box<dyn ProviderAdapter>
1370
+ };
1371
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
1372
+ .expect("capture pass succeeds");
1373
+ assert!(
1374
+ report.assigned.is_empty(),
1375
+ "Claude no-expected + weak (no positive/path) candidate must NOT be assigned; got {:?}",
1376
+ report.assigned
1377
+ );
1378
+ let agent = state
1379
+ .get("agents")
1380
+ .and_then(|a| a.get("release_engineer"))
1381
+ .expect("agent present");
1382
+ assert!(
1383
+ agent.get("session_id").is_none(),
1384
+ "agent.session_id must remain empty when capture is refused; got {agent:?}"
1385
+ );
1386
+ }
1387
+
1388
+ /// P0 RED-2: same shape but with a positive_agent_id_match candidate →
1389
+ /// must capture (positive agent id is authoritative for Claude
1390
+ /// no-expected).
1391
+ #[test]
1392
+ fn claude_no_expected_positive_worker_candidate_still_captures() {
1393
+ let mut state = serde_json::json!({
1394
+ "agents": {
1395
+ "release_engineer": {
1396
+ "provider": "claude",
1397
+ "status": "running",
1398
+ "spawn_cwd": "/tmp/u1-cwd"
1399
+ }
1400
+ }
1401
+ });
1402
+ let mut canned = BTreeMap::new();
1403
+ canned.insert(
1404
+ "release_engineer".to_string(),
1405
+ vec![worker_owned_candidate(
1406
+ "abc12345-worker-owned",
1407
+ "/Users/alauda/.claude/projects/-Users-alauda-team/abc12345.jsonl",
1408
+ )],
1409
+ );
1410
+ let canned_for_adapter = canned.clone();
1411
+ let mut adapter_for = move |provider| {
1412
+ Box::new(
1413
+ test_support::CaptureCandidatesAdapter::new(provider, None, "")
1414
+ .with_candidates(canned_for_adapter.clone()),
1415
+ ) as Box<dyn ProviderAdapter>
1416
+ };
1417
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
1418
+ .expect("capture pass succeeds");
1419
+ assert_eq!(
1420
+ report.assigned,
1421
+ vec!["release_engineer".to_string()],
1422
+ "Claude no-expected + positive_agent_id candidate MUST capture; got {:?}",
1423
+ report.assigned
1424
+ );
1425
+ }
1426
+
1427
+ /// P0 RED-3: leader_receiver under teams.<key> with provider session
1428
+ /// fields must exclude that session from worker capture even if the
1429
+ /// candidate would otherwise match.
1430
+ #[test]
1431
+ fn claimed_session_keys_include_team_scoped_leader_receiver() {
1432
+ let mut state = serde_json::json!({
1433
+ "agents": {
1434
+ "release_engineer": {
1435
+ "provider": "claude",
1436
+ "status": "running",
1437
+ "spawn_cwd": "/tmp/u1-cwd"
1438
+ }
1439
+ },
1440
+ "teams": {
1441
+ "teamA": {
1442
+ "leader_receiver": {
1443
+ "session_id": "leader-session-id-zzz",
1444
+ "rollout_path": "/Users/alauda/.claude/projects/-Users-alauda-team/zzz.jsonl"
1445
+ }
1446
+ }
1447
+ }
1448
+ });
1449
+ let mut canned = BTreeMap::new();
1450
+ canned.insert(
1451
+ "release_engineer".to_string(),
1452
+ vec![worker_owned_candidate(
1453
+ "leader-session-id-zzz",
1454
+ "/Users/alauda/.claude/projects/-Users-alauda-team/zzz.jsonl",
1455
+ )],
1456
+ );
1457
+ let canned_for_adapter = canned.clone();
1458
+ let mut adapter_for = move |provider| {
1459
+ Box::new(
1460
+ test_support::CaptureCandidatesAdapter::new(provider, None, "")
1461
+ .with_candidates(canned_for_adapter.clone()),
1462
+ ) as Box<dyn ProviderAdapter>
1463
+ };
1464
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
1465
+ .expect("capture pass succeeds");
1466
+ assert!(
1467
+ report.assigned.is_empty(),
1468
+ "worker candidate that collides with team-scoped leader_receiver \
1469
+ session MUST be excluded by claimed_provider_session_keys; got {:?}",
1470
+ report.assigned
1471
+ );
1472
+ }
1473
+
1021
1474
  #[test]
1022
1475
  fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
1023
1476
  let mut state = serde_json::json!({
@@ -1096,13 +1549,19 @@ mod u1_tests {
1096
1549
  n
1097
1550
  ));
1098
1551
  std::fs::write(&existing, b"{}\n").expect("write fixture rollout file");
1552
+ // 0.4.6 tuple-atomic contract: `agent_session_complete` now requires
1553
+ // the full authoritative tuple (session_id + rollout_path +
1554
+ // captured_at + captured_via), not just session_id + rollout_path.
1555
+ // Pre-0.4.6 partial tuples are no longer treated as complete.
1099
1556
  let agent_complete = serde_json::json!({
1100
1557
  "session_id": "sess-real",
1101
1558
  "rollout_path": existing.to_string_lossy(),
1559
+ "captured_at": "2026-06-25T10:00:00+00:00",
1560
+ "captured_via": "session.captured",
1102
1561
  });
1103
1562
  assert!(
1104
1563
  agent_session_complete(&agent_complete),
1105
- "an agent with session_id + an existing rollout file remains complete"
1564
+ "0.4.6: agent with full tuple (session_id + rollout_path + captured_at + captured_via) + existing rollout file is complete"
1106
1565
  );
1107
1566
  let _ = std::fs::remove_file(&existing);
1108
1567
 
@@ -1134,6 +1593,12 @@ mod u1_tests {
1134
1593
  /// global one-to-one runs.
1135
1594
  #[test]
1136
1595
  fn capture_allocator_expected_session_id_binds_each_worker_to_its_own_transcript() {
1596
+ // 0.4.6 P0 amendment: Claude no longer carries _pending_session_id
1597
+ // (fresh spawn doesn't inject --session-id; capture anchors on
1598
+ // cwd+spawned_at+identity). The ExpectedSessionId pre-pass remains
1599
+ // valid for providers that DO use expected_session_id (Copilot is the
1600
+ // only one left that honors framework-supplied --session-id). Switch
1601
+ // this test to Copilot to preserve the pre-pass invariant coverage.
1137
1602
  use crate::provider::{CaptureVia, Confidence, RolloutPath};
1138
1603
  let dir = std::env::temp_dir().join(format!(
1139
1604
  "ta-stage1-allocator-{}",
@@ -1154,8 +1619,6 @@ mod u1_tests {
1154
1619
  attribution_confidence: Confidence::High,
1155
1620
  spawn_cwd: dir.clone(),
1156
1621
  },
1157
- // Critical: no positive worker identity hint. Pre-fix this is
1158
- // exactly the shape that fell through to global one-to-one.
1159
1622
  positive_agent_id_match: false,
1160
1623
  agent_path_match: false,
1161
1624
  };
@@ -1174,27 +1637,27 @@ mod u1_tests {
1174
1637
  };
1175
1638
  let mut candidates_by_agent: BTreeMap<String, Vec<CapturedSessionCandidate>> =
1176
1639
  BTreeMap::new();
1177
- candidates_by_agent.insert("claude-a".to_string(), vec![candidate_a.clone()]);
1178
- candidates_by_agent.insert("claude-b".to_string(), vec![candidate_b.clone()]);
1640
+ candidates_by_agent.insert("copilot-a".to_string(), vec![candidate_a.clone()]);
1641
+ candidates_by_agent.insert("copilot-b".to_string(), vec![candidate_b.clone()]);
1179
1642
 
1180
1643
  let cwd_str = dir.to_string_lossy().to_string();
1181
1644
  let mut state = serde_json::json!({
1182
1645
  "agents": {
1183
- "claude-a": {
1184
- "provider": "claude",
1646
+ "copilot-a": {
1647
+ "provider": "copilot",
1185
1648
  "status": "running",
1186
1649
  "spawn_cwd": cwd_str,
1187
1650
  "_pending_session_id": "9a2d1668-8987-4c36-8bde-a5135b10da02"
1188
1651
  },
1189
- "claude-b": {
1190
- "provider": "claude",
1652
+ "copilot-b": {
1653
+ "provider": "copilot",
1191
1654
  "status": "running",
1192
1655
  "spawn_cwd": cwd_str,
1193
1656
  "_pending_session_id": "3e824e89-25ac-4b3f-b272-b4f733f6403c"
1194
1657
  }
1195
1658
  }
1196
1659
  });
1197
- let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Claude, None, "")
1660
+ let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Copilot, None, "")
1198
1661
  .with_candidates(candidates_by_agent);
1199
1662
  let mut adapter_for = move |_provider| {
1200
1663
  Box::new(adapter.clone()) as Box<dyn ProviderAdapter>
@@ -1207,34 +1670,34 @@ mod u1_tests {
1207
1670
  // expected transcript. No cross-assignment, no ambiguous mark.
1208
1671
  let agents = state["agents"].as_object().expect("agents object");
1209
1672
  assert_eq!(
1210
- agents["claude-a"]["session_id"].as_str(),
1673
+ agents["copilot-a"]["session_id"].as_str(),
1211
1674
  Some("9a2d1668-8987-4c36-8bde-a5135b10da02"),
1212
- "Stage 1 fix: claude-a must bind to its own expected transcript; \
1213
- state.claude-a={}",
1214
- agents["claude-a"]
1675
+ "Stage 1 fix: copilot-a must bind to its own expected transcript; \
1676
+ state.copilot-a={}",
1677
+ agents["copilot-a"]
1215
1678
  );
1216
1679
  assert_eq!(
1217
- agents["claude-b"]["session_id"].as_str(),
1680
+ agents["copilot-b"]["session_id"].as_str(),
1218
1681
  Some("3e824e89-25ac-4b3f-b272-b4f733f6403c"),
1219
- "Stage 1 fix: claude-b must bind to its own expected transcript; \
1220
- state.claude-b={}",
1221
- agents["claude-b"]
1682
+ "Stage 1 fix: copilot-b must bind to its own expected transcript; \
1683
+ state.copilot-b={}",
1684
+ agents["copilot-b"]
1222
1685
  );
1223
1686
  assert!(
1224
- agents["claude-a"]
1687
+ agents["copilot-a"]
1225
1688
  .get("attribution_ambiguous")
1226
1689
  .is_none_or(|v| v.as_bool() != Some(true)),
1227
- "Stage 1 fix: claude-a must NOT be flagged ambiguous after the \
1228
- expected-id pre-pass bound it; state.claude-a={}",
1229
- agents["claude-a"]
1690
+ "Stage 1 fix: copilot-a must NOT be flagged ambiguous after the \
1691
+ expected-id pre-pass bound it; state.copilot-a={}",
1692
+ agents["copilot-a"]
1230
1693
  );
1231
1694
  assert!(
1232
- agents["claude-b"]
1695
+ agents["copilot-b"]
1233
1696
  .get("attribution_ambiguous")
1234
1697
  .is_none_or(|v| v.as_bool() != Some(true)),
1235
- "Stage 1 fix: claude-b must NOT be flagged ambiguous; \
1236
- state.claude-b={}",
1237
- agents["claude-b"]
1698
+ "Stage 1 fix: copilot-b must NOT be flagged ambiguous; \
1699
+ state.copilot-b={}",
1700
+ agents["copilot-b"]
1238
1701
  );
1239
1702
  assert_eq!(
1240
1703
  report.ambiguous.len(),
@@ -1246,4 +1709,79 @@ mod u1_tests {
1246
1709
  let _ = std::fs::remove_file(&rollout_b);
1247
1710
  let _ = std::fs::remove_dir(&dir);
1248
1711
  }
1712
+
1713
+ /// E57 RED postflight (lane-046-capture-gap): `recover_resume_session_from_events`
1714
+ /// must refuse to repair a worker's session from a stale `session.captured`
1715
+ /// event whose rollout file is a Claude LEADER transcript
1716
+ /// (`customTitle == "claude leader"`). Without this filter, a fresh restart
1717
+ /// after a pre-fix run still pulls the leader transcript onto a worker via
1718
+ /// captured_via=event_log_repair (Mac mini evidence:
1719
+ /// resume.session_repaired session_id=ea059b82 → release-engineer).
1720
+ #[test]
1721
+ fn e57_recover_resume_refuses_claude_leader_marker_rollout() {
1722
+ use std::io::Write;
1723
+ use std::sync::atomic::{AtomicU64, Ordering};
1724
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
1725
+ let uniq = COUNTER.fetch_add(1, Ordering::Relaxed);
1726
+ let workspace = std::env::temp_dir().join(format!("ta-e57-recover-{}-{}", std::process::id(), uniq));
1727
+ let _ = std::fs::remove_dir_all(&workspace);
1728
+ std::fs::create_dir_all(&workspace).expect("workspace");
1729
+
1730
+ // Write a leader-marker rollout file (claude TUI leader transcript).
1731
+ let rollout = workspace.join("ea059b82-leader.jsonl");
1732
+ let mut f = std::fs::File::create(&rollout).expect("rollout");
1733
+ writeln!(
1734
+ f,
1735
+ "{{\"customTitle\":\"claude leader\",\"sessionId\":\"ea059b82-c53e-4654-9590-9f3e6d46f0ca\",\"cwd\":\"/tmp/e57\"}}"
1736
+ )
1737
+ .unwrap();
1738
+ writeln!(f, "{{\"role\":\"user\",\"content\":\"hi\"}}").unwrap();
1739
+ drop(f);
1740
+
1741
+ // Write a `session.captured` event into events.jsonl pointing
1742
+ // release-engineer at the leader rollout — simulates the stale
1743
+ // pre-fix event that triggered the bug.
1744
+ let event_log = crate::event_log::EventLog::new(&workspace);
1745
+ event_log
1746
+ .write(
1747
+ "session.captured",
1748
+ serde_json::json!({
1749
+ "agent_id": "release-engineer",
1750
+ "provider": "claude",
1751
+ "session_id": "ea059b82-c53e-4654-9590-9f3e6d46f0ca",
1752
+ "rollout_path": rollout.to_string_lossy().to_string(),
1753
+ "captured_via": "fs_watch",
1754
+ }),
1755
+ )
1756
+ .expect("event write");
1757
+
1758
+ let previous = serde_json::json!({
1759
+ "provider": "claude",
1760
+ "session_id": null,
1761
+ "rollout_path": null,
1762
+ });
1763
+ let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Claude, None, "");
1764
+ let exclude = BTreeSet::new();
1765
+ let result = recover_resume_session_from_events(
1766
+ &workspace,
1767
+ "release-engineer",
1768
+ &previous,
1769
+ &adapter,
1770
+ crate::provider::AuthMode::Subscription,
1771
+ &exclude,
1772
+ )
1773
+ .expect("recover call ok");
1774
+
1775
+ assert!(
1776
+ result.is_none(),
1777
+ "E57 postflight: recover_resume_session_from_events must REFUSE \
1778
+ to repair from a Claude leader-marker rollout (event_log_repair \
1779
+ cannot resurrect a stale leader-to-worker assignment); got {result:?}"
1780
+ );
1781
+
1782
+ let _ = std::fs::remove_file(&rollout);
1783
+ let _ = std::fs::remove_file(workspace.join(".team/logs/events.jsonl"));
1784
+ let _ = std::fs::remove_dir_all(workspace.join(".team"));
1785
+ let _ = std::fs::remove_dir_all(&workspace);
1786
+ }
1249
1787
  }