@team-agent/installer 0.4.11 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -3,14 +3,15 @@
3
3
  use std::path::{Path, PathBuf};
4
4
  use std::process::Command;
5
5
 
6
- use super::helpers::{find_session_id, parse_jsonl_records, patterns};
6
+ use super::helpers::patterns;
7
7
  use super::types::{
8
- AuthHintStatus, CaptureVia, CapturedSession, CommandPlan, Confidence, McpConfig,
9
- ProviderCaps, ProviderCommandContext, ProviderError, RolloutPath,
10
- SessionId, StatusPatterns,
8
+ AuthHintStatus, CapturedSession, CommandPlan, McpConfig, ProviderCaps,
9
+ ProviderCommandContext, ProviderError, SessionId, StatusPatterns,
11
10
  };
12
11
  use super::{AuthMode, Provider};
13
12
 
13
+ pub use crate::provider::session_scan::{CapturedSessionCandidate, CaptureSessionContext};
14
+
14
15
  // ===========================================================================
15
16
  // TRAIT: ProviderAdapter (method SIGNATURES only — 无 body)
16
17
  // ===========================================================================
@@ -266,24 +267,6 @@ pub trait ProviderAdapter {
266
267
  }
267
268
  }
268
269
 
269
- #[derive(Debug, Clone, PartialEq, Eq)]
270
- pub struct CaptureSessionContext {
271
- pub agent_id: String,
272
- pub spawn_cwd: PathBuf,
273
- pub pane_id: Option<String>,
274
- pub pane_pid: Option<u32>,
275
- pub spawned_at: Option<String>,
276
- pub expected_session_id: Option<SessionId>,
277
- pub provider_projects_root: Option<PathBuf>,
278
- }
279
-
280
- #[derive(Debug, Clone, PartialEq, Eq)]
281
- pub struct CapturedSessionCandidate {
282
- pub captured: CapturedSession,
283
- pub positive_agent_id_match: bool,
284
- pub agent_path_match: bool,
285
- }
286
-
287
270
  // ===========================================================================
288
271
  // FACADE 自由函数 (doc §71 providers.get_adapter — body unimplemented)
289
272
  // ===========================================================================
@@ -499,7 +482,7 @@ impl ProviderAdapter for BasicProviderAdapter {
499
482
  // expected_session_id caused the apply-time Stage 1 guard to
500
483
  // permanently reject the real Codex transcript. Codex capture must
501
484
  // anchor on (cwd, spawned_at) instead — handled in
502
- // `scan_session_candidates_once` below.
485
+ // `provider/session_scan/codex.rs`.
503
486
  Provider::Codex => Ok(CommandPlan::argv_only(codex_base_command(
504
487
  None,
505
488
  ctx.auth_mode,
@@ -574,7 +557,10 @@ impl ProviderAdapter for BasicProviderAdapter {
574
557
  ) -> Result<Vec<CapturedSessionCandidate>, ProviderError> {
575
558
  let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_s);
576
559
  loop {
577
- let out = scan_session_candidates_once(self.provider, context)?;
560
+ let out = crate::provider::session_scan::scan_session_candidates_once(
561
+ self.provider,
562
+ context,
563
+ )?;
578
564
  if !out.is_empty() || timeout_s == 0 || std::time::Instant::now() >= deadline {
579
565
  return Ok(out);
580
566
  }
@@ -999,303 +985,6 @@ fn claude_context_model(ctx: ProviderCommandContext<'_>) -> Option<&str> {
999
985
  .or(ctx.model)
1000
986
  }
1001
987
 
1002
- fn scan_session_candidates_once(
1003
- provider: Provider,
1004
- context: &CaptureSessionContext,
1005
- ) -> Result<Vec<CapturedSessionCandidate>, ProviderError> {
1006
- // §C4 + cr verdict: copilot session 真相源是 ~/.copilot/session-store.db(sqlite),
1007
- // 不是 jsonl 流。点查 sessions(cwd==spawn_cwd)取最新行,**禁** 走目录扫描
1008
- // (PERF P2 不放大;sqlite 点查天然有界)。decoy 文件不进 parse_session_records,
1009
- // 不会"被毒文件炸"。
1010
- if matches!(provider, Provider::Copilot) {
1011
- return Ok(scan_copilot_session_store(context));
1012
- }
1013
- let candidates = candidate_session_files(provider, context)?;
1014
- let mut out = Vec::new();
1015
- for candidate in candidates {
1016
- let path = candidate.path;
1017
- // P2 (C-P2-1/4) / Python claude.py:432 — bounded HEAD read (session_meta / cwd /
1018
- // sessionId live in the file head; Python stops at 200 lines). A poisoned
1019
- // (invalid UTF-8) tail must not silently drop the candidate the way a
1020
- // whole-file read_to_string did.
1021
- let Ok(text) = read_head_text(&path, CAPTURE_HEAD_BYTES) else {
1022
- continue;
1023
- };
1024
- let records = parse_session_records(&text);
1025
- if records.is_empty() {
1026
- continue;
1027
- }
1028
- if candidate.requires_cwd_match
1029
- && !provider_home_records_match_spawn_cwd(&records, &context.spawn_cwd)
1030
- {
1031
- continue;
1032
- }
1033
- let session_id = records.iter().find_map(find_session_id);
1034
- if matches!(provider, Provider::Claude | Provider::ClaudeCode)
1035
- && session_id.is_some()
1036
- && !records.iter().any(has_cwd_field)
1037
- {
1038
- continue;
1039
- }
1040
- let captured_via = if session_id.is_some() {
1041
- CaptureVia::FsWatch
1042
- } else {
1043
- CaptureVia::FsMtimeFallback
1044
- };
1045
- let attribution_confidence = if session_id.is_some() {
1046
- Confidence::High
1047
- } else {
1048
- Confidence::Low
1049
- };
1050
- let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context);
1051
- let agent_path_match = candidate_path_matches_agent_id(&path, context);
1052
- // P0 (lane-046-capture-gap): Claude leader transcripts must NEVER be
1053
- // returned as a worker capture candidate. The macmini repro showed a
1054
- // 590MB leader transcript (sessionId=ea059b82, customTitle="claude
1055
- // leader") being attributed to a fresh release-engineer worker via the
1056
- // cwd+spawned_at time window. Filter by leader marker in the head
1057
- // records — limited to Claude/ClaudeCode (codex/copilot transcripts
1058
- // don't use customTitle/agentName the same way).
1059
- if matches!(provider, Provider::Claude | Provider::ClaudeCode)
1060
- && claude_records_have_leader_marker(&records)
1061
- {
1062
- continue;
1063
- }
1064
- out.push(CapturedSessionCandidate {
1065
- captured: CapturedSession {
1066
- session_id: session_id.map(SessionId::new),
1067
- rollout_path: Some(RolloutPath::new(path)),
1068
- captured_via,
1069
- attribution_confidence,
1070
- spawn_cwd: context.spawn_cwd.clone(),
1071
- },
1072
- positive_agent_id_match,
1073
- agent_path_match,
1074
- });
1075
- }
1076
- // 0.3.31 Codex capture correction: HARD (cwd, spawned_at) filter.
1077
- // Codex CLI does NOT honor `--session-id` so we cannot use
1078
- // expected_session_id semantics. The only safe identity boundary is:
1079
- // * session_meta.payload.cwd == spawn_cwd (already filtered above via
1080
- // requires_cwd_match), AND
1081
- // * session_meta.payload.timestamp >= spawned_at - small_grace, OR file
1082
- // mtime >= spawned_at - small_grace (the candidate must be POST-spawn).
1083
- // Candidates older than the current spawn are pre-reset / pre-restart
1084
- // remnants and MUST be dropped, not merely de-prioritized — otherwise the
1085
- // single-candidate allocator path picks them, and the Stage 1 mismatch
1086
- // guard later rejects them, producing the 0.4.4 attribution_ambiguous loop.
1087
- if matches!(provider, Provider::Codex) {
1088
- if let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) {
1089
- // 5-second grace: allow for clock skew between Codex's session
1090
- // timestamp and our spawned_at (Codex records LOCAL time before
1091
- // RFC3339-encoding, framework records UTC; small skew possible
1092
- // across midnight or DST boundary).
1093
- let grace = std::time::Duration::from_secs(5);
1094
- let cutoff = spawned_at.checked_sub(grace).unwrap_or(spawned_at);
1095
- out.retain(|candidate| {
1096
- let path = match candidate.captured.rollout_path.as_ref() {
1097
- Some(p) => p.as_path(),
1098
- None => return false,
1099
- };
1100
- std::fs::metadata(path)
1101
- .and_then(|meta| meta.modified())
1102
- .map(|mtime| mtime >= cutoff)
1103
- .unwrap_or(false)
1104
- });
1105
- }
1106
- }
1107
-
1108
- // E6 层1·C(机会性兜底):若盘上真有 expected_session_id 命名的 transcript(claude 哪天
1109
- // 真采用 --session-id,或别的 provider 本就采用),直接唯一命中,省去时间窗扫描。
1110
- // 命不中(交互式 claude 现实:不落 <expected>.jsonl)→ 回落 B。
1111
- if let Some(expected) = context.expected_session_id.as_ref() {
1112
- if let Some(hit) = out.iter().find(|candidate| {
1113
- candidate
1114
- .captured
1115
- .session_id
1116
- .as_ref()
1117
- .is_some_and(|session| session.as_str() == expected.as_str())
1118
- }) {
1119
- return Ok(vec![hit.clone()]);
1120
- }
1121
- // Stage 1 (identity-boundary unified plan, architect direction
1122
- // 2026-06-23): for Claude/ClaudeCode with an expected session id,
1123
- // an exact-id miss MUST NOT fall back to same-cwd latest. Pre-fix
1124
- // the scanner returned every same-cwd candidate sorted with
1125
- // expected-first; with no exact match, that handed `frontend`'s
1126
- // transcript to `reviewer` in the AI-sync repro. Mirror Copilot's
1127
- // stricter contract: keep only candidates with a positive worker
1128
- // identity match (TEAM_AGENT_ID literal in the transcript head, or
1129
- // path-encoded agent_id). If none, return empty — capture stays
1130
- // pending/ambiguous, which is safer than misattribution.
1131
- //
1132
- // Bug 2 (0.4.2 P0) reinforcement: even when positive-identity
1133
- // filtering returns no candidates, do NOT let downstream
1134
- // time-window narrowing run for Claude when expected_session_id
1135
- // misses — that's exactly the leader-session-leak symptom the
1136
- // architect cataloged in .team/artifacts/bug-042-restart-scope-and-session.md.
1137
- if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
1138
- let positive_only: Vec<CapturedSessionCandidate> = out
1139
- .iter()
1140
- .filter(|candidate| candidate.positive_agent_id_match || candidate.agent_path_match)
1141
- .cloned()
1142
- .collect();
1143
- return Ok(positive_only);
1144
- }
1145
- }
1146
- // E6 层1·B(主路径,交互式现实):cwd 匹配但盘上有多个 sibling transcript(claude 自生成,
1147
- // 不采用预定 UUID)→ 用 spawn 时间窗唯一选:只留 mtime >= spawned_at 的候选,打破歧义。
1148
- // spawned_at 缺/无法解析时不收窄(保守,维持既有行为)。
1149
- if context.expected_session_id.is_none() || out.len() > 1 {
1150
- if let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) {
1151
- let within: Vec<CapturedSessionCandidate> = out
1152
- .iter()
1153
- .filter(|candidate| {
1154
- candidate
1155
- .captured
1156
- .rollout_path
1157
- .as_ref()
1158
- .and_then(|p| std::fs::metadata(p.as_path()).and_then(|m| m.modified()).ok())
1159
- .is_some_and(|mtime| mtime >= spawned_at)
1160
- })
1161
- .cloned()
1162
- .collect();
1163
- // 只有当时间窗把候选收成唯一时才采用(收成 0 或仍多义则不强行,交给上层 ambiguous)。
1164
- if within.len() == 1 {
1165
- return Ok(within);
1166
- }
1167
- }
1168
- }
1169
- // Non-Claude / non-strict providers with an expected id but no exact
1170
- // match: order expected-first so the allocator's `unique_available_candidate`
1171
- // sees the deterministically preferred candidate. Claude/ClaudeCode took
1172
- // the strict positive-only return above and never reaches here.
1173
- if let Some(expected) = context.expected_session_id.as_ref() {
1174
- out.sort_by_key(|candidate| {
1175
- candidate
1176
- .captured
1177
- .session_id
1178
- .as_ref()
1179
- .is_none_or(|session| session.as_str() != expected.as_str())
1180
- });
1181
- }
1182
- Ok(out)
1183
- }
1184
-
1185
- /// 解析 state 里的 `spawned_at`(RFC3339)为 SystemTime,用于 spawn 时间窗候选筛选。
1186
- /// 解析失败 → None(调用方据此不收窄时间窗,保守维持既有行为)。
1187
- fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
1188
- chrono::DateTime::parse_from_rfc3339(raw)
1189
- .ok()
1190
- .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
1191
- }
1192
-
1193
- /// E6 层1·B:把 spawn cwd 映射成 claude transcript 子目录 `~/.claude/projects/<encoded>`。
1194
- /// claude 用 **canonical(realpath)** cwd 且把每个 `/` 替换成 `-`(实证:cwd
1195
- /// `/private/tmp/x` → `-private-tmp-x`;macOS `/tmp`→`/private/tmp` 必须先 canonical)。
1196
- /// canonical 失败(目录已不在)退回原始路径,仍尽力编码。dir 不存在也返回(调用方
1197
- /// `collect_optional_candidate_files` 对不存在目录是 no-op)。
1198
- fn claude_projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
1199
- let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
1200
- let encoded = encode_claude_projects_dir(&canonical.to_string_lossy());
1201
- if encoded.is_empty() {
1202
- return None;
1203
- }
1204
- Some(home.join(".claude").join("projects").join(encoded))
1205
- }
1206
-
1207
- /// 0.4.6 Stage 4: Claude CLI's project-dir encoding rule. Collapse every
1208
- /// non-ASCII-alphanumeric character (slashes, dots, spaces, punctuation,
1209
- /// non-ASCII codepoints like Chinese) into a single `-`. The pre-fix
1210
- /// implementation only replaced `/` → `-` which silently produced wrong
1211
- /// directory names for any cwd containing Chinese / spaces / dots
1212
- /// (`/Users/alauda/.../agent前沿探索/多agent协作` produced raw UTF-8 while
1213
- /// Claude actually creates `-Users-alauda-...-agent------agent--`).
1214
- ///
1215
- /// Note: each non-alnum CHARACTER produces one `-`. A 2-char Chinese word
1216
- /// like "协作" becomes 2 dashes. Adjacent non-alnums each contribute one
1217
- /// dash (Claude does NOT collapse runs).
1218
- fn encode_claude_projects_dir(path: &str) -> String {
1219
- let mut out = String::with_capacity(path.len());
1220
- for c in path.chars() {
1221
- if c.is_ascii_alphanumeric() {
1222
- out.push(c);
1223
- } else {
1224
- out.push('-');
1225
- }
1226
- }
1227
- out
1228
- }
1229
-
1230
- /// §C4 cr verdict — copilot session 真相源 sqlite 点查。
1231
- ///
1232
- /// 路径:`<HOME>/.copilot/session-store.db`,sessions 表(id/cwd/created_at/updated_at)
1233
- /// where `cwd == context.spawn_cwd` 取 updated_at 最新行。**绝不**全文件扫描、**绝不**
1234
- /// 走 `parse_session_records`(jsonl)路径 → decoy 毒文件不会触碰任何解析器。
1235
- ///
1236
- /// 失败(HOME 缺、db 缺、表缺、sqlite 错)统一返回空 candidate 列表,与既有
1237
- /// `collect_optional_candidate_files` 同精神(absent → empty)。
1238
- fn scan_copilot_session_store(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
1239
- let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
1240
- return Vec::new();
1241
- };
1242
- let db_path = home.join(".copilot").join("session-store.db");
1243
- if !db_path.exists() {
1244
- return Vec::new();
1245
- }
1246
- let Ok(conn) = rusqlite::Connection::open_with_flags(
1247
- &db_path,
1248
- rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1249
- ) else {
1250
- return Vec::new();
1251
- };
1252
- // E11 层1(本机实锤):copilot honor `--session-id`(sessions.id == 注入的 expected_session_id),
1253
- // 故 worker 权威 id 可靠在 db。**expected-id 优先点查**:命中即返(High,直接根治 leader/worker
1254
- // 同 cwd 共享 db 时 latest-wins 误抓 leader 的 bug)。expected 查无 → **不 promote**(E6 铁律:
1255
- // 不硬写不在盘的假 session),回落 cwd-latest 让收敛重试。
1256
- if let Some(expected) = context.expected_session_id.as_ref() {
1257
- let hit: Option<String> = conn
1258
- .prepare("select id from sessions where id = ?1 limit 1")
1259
- .ok()
1260
- .and_then(|mut stmt| {
1261
- stmt.query_row([expected.as_str()], |row| row.get::<_, String>(0)).ok()
1262
- });
1263
- if let Some(session_id) = hit {
1264
- return vec![copilot_candidate(session_id, &db_path, context)];
1265
- }
1266
- // expected 设了但 db 无该 id → 返空(收敛重试),绝不回落抓别人(尤其 leader)的 latest。
1267
- return Vec::new();
1268
- }
1269
- // 无 expected → **返空**(保守,不 cwd-latest 猜)。
1270
- // E11 层1 兜底洞(architect 核实):leader+worker 同 cwd 共享 db 时,cwd-latest 可能抓 leader
1271
- // 的 session;而 allocator 的 claimed 去重只扫 state.agents,**leader 在 state.leader/team_owner
1272
- // 不在 agents → 兜不住**;且 leader 的 copilot session_id 运行期不入 state(team_owner 只存
1273
- // leader_session_uuid,非 copilot db id),故无从显式排除。所幸 copilot build_command_plan **总**
1274
- // 注入 --session-id(expected_session_id 恒 Some)→ 真实 copilot worker 永走上面点查路径,
1275
- // 此 expected=None 分支对真实 worker 不可达。故直接返空最干净:不猜、绝不把 leader session 分给
1276
- // worker。db 留 _。
1277
- let _ = (&db_path, &conn);
1278
- Vec::new()
1279
- }
1280
-
1281
- fn copilot_candidate(
1282
- session_id: String,
1283
- db_path: &Path,
1284
- context: &CaptureSessionContext,
1285
- ) -> CapturedSessionCandidate {
1286
- CapturedSessionCandidate {
1287
- captured: CapturedSession {
1288
- session_id: Some(SessionId::new(session_id)),
1289
- rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
1290
- captured_via: CaptureVia::FsWatch,
1291
- attribution_confidence: Confidence::High,
1292
- spawn_cwd: context.spawn_cwd.clone(),
1293
- },
1294
- positive_agent_id_match: false,
1295
- agent_path_match: false,
1296
- }
1297
- }
1298
-
1299
988
  fn command_on_path(name: &str) -> bool {
1300
989
  let Some(path) = std::env::var_os("PATH") else {
1301
990
  return false;
@@ -1303,251 +992,6 @@ fn command_on_path(name: &str) -> bool {
1303
992
  std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
1304
993
  }
1305
994
 
1306
- struct SessionCandidate {
1307
- path: PathBuf,
1308
- requires_cwd_match: bool,
1309
- }
1310
-
1311
- fn candidate_session_files(
1312
- provider: Provider,
1313
- context: &CaptureSessionContext,
1314
- ) -> Result<Vec<SessionCandidate>, ProviderError> {
1315
- let mut out = Vec::new();
1316
- if let Some(root) = context.provider_projects_root.as_ref() {
1317
- collect_optional_candidate_files(root, &context.agent_id, &mut out)?;
1318
- }
1319
- collect_candidate_files(&context.spawn_cwd, &context.agent_id, 0, false, &mut out)?;
1320
- if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
1321
- match provider {
1322
- Provider::Codex => {
1323
- collect_optional_candidate_files(&home.join(".codex").join("sessions"), &context.agent_id, &mut out)?;
1324
- }
1325
- Provider::Claude | Provider::ClaudeCode => {
1326
- // Blocker-2 Layer-1 (prerelease 0.4.0): the activity rollout_path
1327
- // MUST be the real transcript under .claude/projects/<encoded cwd>/
1328
- // <session_id>.jsonl. ~/.claude/sessions/<pid>.json is a small
1329
- // session-metadata file (~300-400 bytes) that contains a
1330
- // sessionId but no lifecycle records; if it wins capture, the
1331
- // activity classifier reads it forever and never sees the real
1332
- // transcript. Architect verdict (bugs-prerelease-blockers.md §138):
1333
- // do not let .claude/sessions/<pid>.json become rollout_path.
1334
- // Skip that directory entirely; resume backing probe still scans
1335
- // it via its own path checks in restart/common.rs.
1336
- // E6 层1·B:优先锚到 ~/.claude/projects/<canonical spawn_cwd 编码> 子目录
1337
- // (claude 把 cwd 的 '/' 编码成 '-';交互式 worker 的真实 transcript 落在此),
1338
- // 而非全 projects 树盲扫(交互式 claude 自生成 UUID,锚 cwd 子目录 + 时间窗才能唯一选)。
1339
- if let Some(dir) = claude_projects_dir_for_cwd(&home, &context.spawn_cwd) {
1340
- collect_optional_candidate_files(&dir, &context.agent_id, &mut out)?;
1341
- }
1342
- collect_optional_candidate_files(&home.join(".claude").join("projects"), &context.agent_id, &mut out)?;
1343
- }
1344
- // §C4 cr verdict + 设计 §C: copilot session 真相源是 ~/.copilot/session-store.db
1345
- // (sqlite 点查 sessions.cwd==spawn_cwd 最新行)和 ~/.copilot/session-state/<uuid>/
1346
- // workspace.yaml — **不走全文件扫描**(PERF P2 禁不放大)。主路径是
1347
- // build_command_plan 预定 UUID(--session-id <expected>)→ pending_session_id
1348
- // 直接命中,这里只补 sqlite 查询的二期入口。一期返空,resume 走 caps 校验。
1349
- Provider::Copilot | Provider::GeminiCli | Provider::Fake => {}
1350
- }
1351
- }
1352
- out.sort_by(|a, b| {
1353
- a.requires_cwd_match
1354
- .cmp(&b.requires_cwd_match)
1355
- .then_with(|| a.path.to_string_lossy().cmp(&b.path.to_string_lossy()))
1356
- });
1357
- out.dedup_by(|a, b| a.path == b.path && a.requires_cwd_match == b.requires_cwd_match);
1358
- cap_candidates_by_mtime(&mut out, CAPTURE_CANDIDATE_CAP);
1359
- Ok(out)
1360
- }
1361
-
1362
- /// P2 (C-P2-2/3) / Python claude.py:300 — candidates are capped to the newest `cap`
1363
- /// by mtime (descending priority: old candidates must not crowd out new ones; the cap
1364
- /// may be raised above Python's 300 but never lowered). The existing selection
1365
- /// ordering of the survivors is preserved.
1366
- const CAPTURE_CANDIDATE_CAP: usize = 300;
1367
-
1368
- /// P2 (C-P2-1): head window ≥ Python's 200-line read (meta fields live in the head).
1369
- const CAPTURE_HEAD_BYTES: u64 = 65_536;
1370
-
1371
- fn cap_candidates_by_mtime(out: &mut Vec<SessionCandidate>, cap: usize) {
1372
- if out.len() <= cap {
1373
- return;
1374
- }
1375
- let mut ranked: Vec<(std::time::SystemTime, usize)> = out
1376
- .iter()
1377
- .enumerate()
1378
- .map(|(index, candidate)| {
1379
- let mtime = std::fs::metadata(&candidate.path)
1380
- .and_then(|meta| meta.modified())
1381
- .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1382
- (mtime, index)
1383
- })
1384
- .collect();
1385
- ranked.sort_by(|a, b| b.0.cmp(&a.0));
1386
- let keep: std::collections::BTreeSet<usize> =
1387
- ranked.into_iter().take(cap).map(|(_, index)| index).collect();
1388
- let mut index = 0;
1389
- out.retain(|_| {
1390
- let kept = keep.contains(&index);
1391
- index += 1;
1392
- kept
1393
- });
1394
- }
1395
-
1396
- /// P2: bounded head read, truncated to the last complete line (a cut record must not
1397
- /// reach the JSONL parser); lossy UTF-8 so a mid-codepoint boundary stays safe.
1398
- fn read_head_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
1399
- use std::io::Read;
1400
- let file = std::fs::File::open(path)?;
1401
- let mut bytes = Vec::new();
1402
- file.take(max_bytes).read_to_end(&mut bytes)?;
1403
- let complete = match bytes.iter().rposition(|byte| *byte == b'\n') {
1404
- Some(last_newline) => &bytes[..=last_newline],
1405
- None => &bytes[..],
1406
- };
1407
- Ok(String::from_utf8_lossy(complete).into_owned())
1408
- }
1409
-
1410
- fn collect_optional_candidate_files(
1411
- dir: &Path,
1412
- agent_id: &str,
1413
- out: &mut Vec<SessionCandidate>,
1414
- ) -> Result<(), ProviderError> {
1415
- if dir.exists() {
1416
- let _ = collect_candidate_files(dir, agent_id, 0, true, out);
1417
- }
1418
- Ok(())
1419
- }
1420
-
1421
- fn collect_candidate_files(
1422
- dir: &Path,
1423
- agent_id: &str,
1424
- depth: usize,
1425
- requires_cwd_match: bool,
1426
- out: &mut Vec<SessionCandidate>,
1427
- ) -> Result<(), ProviderError> {
1428
- if depth > 4 {
1429
- return Ok(());
1430
- }
1431
- let entries = match std::fs::read_dir(dir) {
1432
- Ok(entries) => entries,
1433
- Err(e) if depth == 0 => return Err(ProviderError::Io(format!("{}: {e}", dir.display()))),
1434
- Err(_) => return Ok(()),
1435
- };
1436
- for entry in entries {
1437
- let Ok(entry) = entry else {
1438
- continue;
1439
- };
1440
- let path = entry.path();
1441
- if path.is_dir() {
1442
- collect_candidate_files(&path, agent_id, depth.saturating_add(1), requires_cwd_match, out)?;
1443
- } else if looks_like_session_file(&path, agent_id) {
1444
- out.push(SessionCandidate {
1445
- path,
1446
- requires_cwd_match,
1447
- });
1448
- }
1449
- }
1450
- Ok(())
1451
- }
1452
-
1453
- fn looks_like_session_file(path: &Path, agent_id: &str) -> bool {
1454
- // F5/N11/Contract D: Team Agent's own runtime/log JSONL must never be picked up
1455
- // as a provider transcript. `.team/logs/events.jsonl` and the rest of the
1456
- // `.team/runtime/` tree are framework files, not Codex/Claude rollout. Reject
1457
- // anything under `.team/` BEFORE the filename-shape match, so honest "no valid
1458
- // rollout" yields `rollout_path=None` instead of fake idle from our own logs.
1459
- if path_is_under_team_runtime(path) {
1460
- return false;
1461
- }
1462
- let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
1463
- return false;
1464
- };
1465
- name.ends_with(".jsonl")
1466
- || name.ends_with(".json")
1467
- || name.contains("session")
1468
- || name.contains("rollout")
1469
- || (!agent_id.is_empty() && name.contains(agent_id))
1470
- }
1471
-
1472
- fn parse_session_records(text: &str) -> Vec<serde_json::Value> {
1473
- match serde_json::from_str::<serde_json::Value>(text) {
1474
- Ok(serde_json::Value::Array(items)) => items,
1475
- Ok(value) => vec![value],
1476
- Err(_) => parse_jsonl_records(text),
1477
- }
1478
- }
1479
-
1480
- fn provider_home_records_match_spawn_cwd(records: &[serde_json::Value], spawn_cwd: &Path) -> bool {
1481
- let cwd_values: Vec<String> = records.iter().filter_map(record_cwd).collect();
1482
- !cwd_values.is_empty()
1483
- && cwd_values
1484
- .iter()
1485
- .any(|cwd| paths_equivalent(Path::new(cwd), spawn_cwd))
1486
- }
1487
-
1488
- fn candidate_text_has_team_agent_id(text: &str, context: &CaptureSessionContext) -> bool {
1489
- let id = context.agent_id.as_str();
1490
- if id.is_empty() {
1491
- return false;
1492
- }
1493
- [
1494
- format!("\"TEAM_AGENT_ID\":\"{id}\""),
1495
- format!("\"TEAM_AGENT_ID\": \"{id}\""),
1496
- format!("TEAM_AGENT_ID={id}"),
1497
- format!("env.TEAM_AGENT_ID=\"{id}\""),
1498
- format!("env.TEAM_AGENT_ID=\\\"{id}\\\""),
1499
- format!("\"TEAM_AGENT_AGENT_ID\":\"{id}\""),
1500
- format!("\"TEAM_AGENT_AGENT_ID\": \"{id}\""),
1501
- format!("TEAM_AGENT_AGENT_ID={id}"),
1502
- ]
1503
- .iter()
1504
- .any(|needle| text.contains(needle))
1505
- }
1506
-
1507
- fn candidate_path_matches_agent_id(path: &Path, context: &CaptureSessionContext) -> bool {
1508
- let id = context.agent_id.as_str();
1509
- if id.is_empty() {
1510
- return false;
1511
- }
1512
- let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
1513
- return false;
1514
- };
1515
- let dashed = id.replace('_', "-");
1516
- name.contains(id) || name.contains(&dashed)
1517
- }
1518
-
1519
- fn record_cwd(record: &serde_json::Value) -> Option<String> {
1520
- record
1521
- .get("cwd")
1522
- .and_then(serde_json::Value::as_str)
1523
- .or_else(|| {
1524
- record
1525
- .get("session_meta")
1526
- .and_then(|v| v.get("payload"))
1527
- .or_else(|| record.get("payload"))
1528
- .and_then(|v| v.get("cwd"))
1529
- .and_then(serde_json::Value::as_str)
1530
- })
1531
- .map(ToString::to_string)
1532
- }
1533
-
1534
- fn paths_equivalent(left: &Path, right: &Path) -> bool {
1535
- if left == right {
1536
- return true;
1537
- }
1538
- let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
1539
- let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
1540
- left == right || left.parent().is_some_and(|parent| parent == right)
1541
- }
1542
-
1543
- /// `true` iff any path component is `.team` (the Team Agent runtime/logs root) — used
1544
- /// to gate session-file detection so `<workspace>/.team/logs/events.jsonl`,
1545
- /// `.team/runtime/team.db`, etc. are NEVER mistaken for a provider transcript.
1546
- fn path_is_under_team_runtime(path: &Path) -> bool {
1547
- path.components()
1548
- .any(|c| c.as_os_str() == std::ffi::OsStr::new(".team"))
1549
- }
1550
-
1551
995
  pub(crate) fn prompt_needs_native_mcp(prompt: &str) -> bool {
1552
996
  prompt.contains('\n') || prompt.contains('"')
1553
997
  }
@@ -1613,57 +1057,6 @@ fn current_team_agent_command() -> String {
1613
1057
  "/usr/local/bin/team-agent".to_string()
1614
1058
  }
1615
1059
 
1616
- /// P0 (lane-046-capture-gap): detect Claude leader transcripts by their head
1617
- /// marker records. A leader transcript head contains records of the form
1618
- /// `{"type":"custom-title","customTitle":"claude leader",...}` or
1619
- /// `{"type":"agent-name","agentName":"claude leader",...}` written when the
1620
- /// leader pane starts. Workers must never be attributed to such transcripts.
1621
- /// E57 (lane-046-capture-gap postflight): expose the Claude leader-marker
1622
- /// scanner for the event-log repair path. `recover_resume_session_from_events`
1623
- /// must apply the SAME marker filter the capture allocator applies, otherwise
1624
- /// a stale `session.captured` event from a pre-fix run still pulls the leader
1625
- /// transcript onto a worker on the next restart.
1626
- ///
1627
- /// Returns `true` ONLY for Claude/ClaudeCode providers when the rollout file's
1628
- /// head records carry `customTitle == "claude leader"` or `agentName ==
1629
- /// "claude leader"` (case-insensitive). Other providers always return `false`
1630
- /// — codex/copilot transcripts don't use those fields the same way.
1631
- pub(crate) fn rollout_path_has_claude_leader_marker(
1632
- provider: crate::provider::Provider,
1633
- rollout_path: &Path,
1634
- ) -> bool {
1635
- if !matches!(
1636
- provider,
1637
- crate::provider::Provider::Claude | crate::provider::Provider::ClaudeCode
1638
- ) {
1639
- return false;
1640
- }
1641
- let Ok(text) = read_head_text(rollout_path, CAPTURE_HEAD_BYTES) else {
1642
- return false;
1643
- };
1644
- let records = parse_session_records(&text);
1645
- claude_records_have_leader_marker(&records)
1646
- }
1647
-
1648
- fn claude_records_have_leader_marker(records: &[serde_json::Value]) -> bool {
1649
- records.iter().any(|record| {
1650
- let custom_title = record
1651
- .get("customTitle")
1652
- .and_then(serde_json::Value::as_str)
1653
- .map(str::to_ascii_lowercase);
1654
- let agent_name = record
1655
- .get("agentName")
1656
- .and_then(serde_json::Value::as_str)
1657
- .map(str::to_ascii_lowercase);
1658
- matches!(custom_title.as_deref(), Some("claude leader"))
1659
- || matches!(agent_name.as_deref(), Some("claude leader"))
1660
- })
1661
- }
1662
-
1663
- fn has_cwd_field(record: &serde_json::Value) -> bool {
1664
- record_cwd(record).is_some()
1665
- }
1666
-
1667
1060
  pub(crate) fn next_session_token() -> String {
1668
1061
  use sha2::Digest;
1669
1062
 
@@ -1702,424 +1095,3 @@ pub(crate) fn next_session_token() -> String {
1702
1095
  bytes[15],
1703
1096
  )
1704
1097
  }
1705
-
1706
- #[cfg(test)]
1707
- mod lane_046_leader_marker_tests {
1708
- use super::*;
1709
-
1710
- #[test]
1711
- fn claude_leader_marker_in_custom_title_is_detected() {
1712
- let records = vec![serde_json::json!({
1713
- "type": "custom-title",
1714
- "customTitle": "claude leader",
1715
- "sessionId": "ea059b82",
1716
- })];
1717
- assert!(
1718
- claude_records_have_leader_marker(&records),
1719
- "customTitle=='claude leader' must be detected as leader marker"
1720
- );
1721
- }
1722
-
1723
- #[test]
1724
- fn claude_leader_marker_in_agent_name_is_detected() {
1725
- let records = vec![serde_json::json!({
1726
- "type": "agent-name",
1727
- "agentName": "claude leader",
1728
- "sessionId": "ea059b82",
1729
- })];
1730
- assert!(
1731
- claude_records_have_leader_marker(&records),
1732
- "agentName=='claude leader' must be detected as leader marker"
1733
- );
1734
- }
1735
-
1736
- #[test]
1737
- fn claude_worker_records_have_no_leader_marker() {
1738
- let records = vec![
1739
- serde_json::json!({
1740
- "type": "custom-title",
1741
- "customTitle": "claude release-engineer",
1742
- "sessionId": "abc12345",
1743
- }),
1744
- serde_json::json!({
1745
- "type": "user",
1746
- "content": "Team Agent message from leader: do X",
1747
- "sessionId": "abc12345",
1748
- }),
1749
- ];
1750
- assert!(
1751
- !claude_records_have_leader_marker(&records),
1752
- "worker transcripts must NOT be flagged as leader marker (the \
1753
- literal 'claude leader' in user content does not count — only \
1754
- customTitle/agentName fields)"
1755
- );
1756
- }
1757
-
1758
- #[test]
1759
- fn claude_leader_marker_is_case_insensitive() {
1760
- let records = vec![serde_json::json!({
1761
- "customTitle": "Claude Leader",
1762
- })];
1763
- assert!(
1764
- claude_records_have_leader_marker(&records),
1765
- "leader marker detection must be case-insensitive"
1766
- );
1767
- }
1768
- }
1769
-
1770
- #[cfg(test)]
1771
- mod e6_session_attribution_tests {
1772
- #![allow(clippy::unwrap_used)]
1773
- use super::*;
1774
- use std::sync::atomic::{AtomicU64, Ordering};
1775
-
1776
- fn tmp_root(tag: &str) -> PathBuf {
1777
- static CTR: AtomicU64 = AtomicU64::new(0);
1778
- let dir = std::env::temp_dir().join(format!(
1779
- "ta-e6-attr-{}-{}-{}",
1780
- tag,
1781
- std::process::id(),
1782
- CTR.fetch_add(1, Ordering::Relaxed)
1783
- ));
1784
- std::fs::create_dir_all(&dir).unwrap();
1785
- dir
1786
- }
1787
-
1788
- fn write_transcript(dir: &Path, uuid: &str, cwd: &Path) -> PathBuf {
1789
- std::fs::create_dir_all(dir).unwrap();
1790
- let path = dir.join(format!("{uuid}.jsonl"));
1791
- let line = serde_json::json!({
1792
- "sessionId": uuid,
1793
- "cwd": cwd.to_string_lossy(),
1794
- });
1795
- std::fs::write(&path, format!("{line}\n")).unwrap();
1796
- path
1797
- }
1798
-
1799
- #[test]
1800
- fn claude_projects_dir_for_cwd_encodes_slashes_to_dashes() {
1801
- let home = Path::new("/home/u");
1802
- // 用一个真实存在的 cwd 让 canonicalize 成功(否则退回原始);用 tmp。
1803
- let cwd = tmp_root("encode");
1804
- let got = claude_projects_dir_for_cwd(home, &cwd).unwrap();
1805
- let canon = std::fs::canonicalize(&cwd).unwrap();
1806
- let expected_leaf = encode_claude_projects_dir(&canon.to_string_lossy());
1807
- assert_eq!(got, home.join(".claude").join("projects").join(expected_leaf));
1808
- let _ = std::fs::remove_dir_all(&cwd);
1809
- }
1810
-
1811
- /// **0.4.6 Stage 4 RED**: encode every non-[A-Za-z0-9] character to `-`,
1812
- /// matching Claude CLI's actual project-dir naming rule. Pre-fix code
1813
- /// only replaced `/` → `-` and silently produced wrong directories for
1814
- /// non-ASCII / dotted / spaced cwds — invisible to the scanner.
1815
- #[test]
1816
- fn encode_claude_projects_dir_parity_with_real_claude_naming() {
1817
- // Pure-ASCII parity (the pre-fix happy path).
1818
- assert_eq!(
1819
- encode_claude_projects_dir("/Users/alauda/code"),
1820
- "-Users-alauda-code"
1821
- );
1822
- // The user's actual project root (Chinese):
1823
- // /Users/alauda/Documents/code/agent前沿探索/多agent协作
1824
- // Each Chinese character → one `-`. "前沿探索"=4 chars → 4 dashes,
1825
- // "多agent协作" = "多"+"agent"+"协作" = 1 + (alphanumeric kept) + 2.
1826
- assert_eq!(
1827
- encode_claude_projects_dir(
1828
- "/Users/alauda/Documents/code/agent前沿探索/多agent协作"
1829
- ),
1830
- "-Users-alauda-Documents-code-agent------agent--"
1831
- );
1832
- // Dots and spaces also collapse.
1833
- assert_eq!(
1834
- encode_claude_projects_dir("/Users/foo bar.baz/v1.2"),
1835
- "-Users-foo-bar-baz-v1-2"
1836
- );
1837
- // Hidden directory ".team" → "-team".
1838
- assert_eq!(
1839
- encode_claude_projects_dir("/proj/.team/runtime"),
1840
- "-proj--team-runtime"
1841
- );
1842
- }
1843
-
1844
- #[test]
1845
- fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
1846
- assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
1847
- assert!(parse_spawned_at("not-a-date").is_none());
1848
- assert!(parse_spawned_at("").is_none());
1849
- }
1850
-
1851
- #[test]
1852
- fn scan_expected_session_id_hit_returns_only_that_candidate() {
1853
- // C 兜底:盘上恰有 <expected>.jsonl(假设 claude 哪天真采用)→ 唯一命中,忽略 sibling。
1854
- let base = tmp_root("c-hit");
1855
- let cwd = base.join("ws");
1856
- std::fs::create_dir_all(&cwd).unwrap();
1857
- let proj = base.join("projects");
1858
- write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
1859
- write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
1860
- let ctx = CaptureSessionContext {
1861
- agent_id: "w1".to_string(),
1862
- spawn_cwd: cwd.clone(),
1863
- pane_id: None,
1864
- pane_pid: None,
1865
- spawned_at: None,
1866
- expected_session_id: Some(SessionId::new("22222222-2222-4222-8222-222222222222")),
1867
- provider_projects_root: Some(proj.clone()),
1868
- };
1869
- let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1870
- assert_eq!(out.len(), 1, "expected-id hit must collapse to the single match");
1871
- assert_eq!(
1872
- out[0].captured.session_id.as_ref().unwrap().as_str(),
1873
- "22222222-2222-4222-8222-222222222222"
1874
- );
1875
- let _ = std::fs::remove_dir_all(&base);
1876
- }
1877
-
1878
- #[test]
1879
- fn scan_spawn_time_window_disambiguates_two_siblings() {
1880
- // B 主路径:claude 不采用预定 UUID,盘上两个自生成 sibling 都匹配 cwd。
1881
- // 只有一个在 spawn 时间窗内(mtime >= spawned_at)→ 时间窗唯一选出它。
1882
- let base = tmp_root("b-window");
1883
- let cwd = base.join("ws");
1884
- std::fs::create_dir_all(&cwd).unwrap();
1885
- let proj = base.join("projects");
1886
- let old = write_transcript(&proj, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &cwd);
1887
- let new = write_transcript(&proj, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", &cwd);
1888
- // 把 old 的 mtime 设到很久以前,new 保持现在;spawned_at = 两者之间。
1889
- let long_ago = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
1890
- filetime_set(&old, long_ago);
1891
- // spawned_at 取一个介于 old 与 new 之间、肯定早于 new 真实 mtime 的时刻(2020 年)。
1892
- let ctx = CaptureSessionContext {
1893
- agent_id: "w1".to_string(),
1894
- spawn_cwd: cwd.clone(),
1895
- pane_id: None,
1896
- pane_pid: None,
1897
- spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
1898
- expected_session_id: None,
1899
- provider_projects_root: Some(proj.clone()),
1900
- };
1901
- let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1902
- assert_eq!(out.len(), 1, "time window must collapse two siblings to one");
1903
- assert_eq!(
1904
- out[0].captured.session_id.as_ref().unwrap().as_str(),
1905
- "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
1906
- "the in-window (recent) transcript must win"
1907
- );
1908
- let _ = new;
1909
- let _ = std::fs::remove_dir_all(&base);
1910
- }
1911
-
1912
- #[test]
1913
- fn scan_no_spawned_at_keeps_both_siblings_ambiguous() {
1914
- // 保守:spawned_at 缺 → 不收窄时间窗,两 sibling 仍并存(交上层 ambiguous 处理)。
1915
- let base = tmp_root("b-noamb");
1916
- let cwd = base.join("ws");
1917
- std::fs::create_dir_all(&cwd).unwrap();
1918
- let proj = base.join("projects");
1919
- write_transcript(&proj, "cccccccc-cccc-4ccc-8ccc-cccccccccccc", &cwd);
1920
- write_transcript(&proj, "dddddddd-dddd-4ddd-8ddd-dddddddddddd", &cwd);
1921
- let ctx = CaptureSessionContext {
1922
- agent_id: "w1".to_string(),
1923
- spawn_cwd: cwd.clone(),
1924
- pane_id: None,
1925
- pane_pid: None,
1926
- spawned_at: None,
1927
- expected_session_id: None,
1928
- provider_projects_root: Some(proj.clone()),
1929
- };
1930
- let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1931
- assert!(out.len() >= 2, "no spawned_at → no time-window narrowing");
1932
- let _ = std::fs::remove_dir_all(&base);
1933
- }
1934
-
1935
- fn filetime_set(path: &Path, when: std::time::SystemTime) {
1936
- // 用 utimensat 经 std:无直接 set_mtime,借 filetime-free 方式:写后用 File::set_modified。
1937
- let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1938
- f.set_modified(when).unwrap();
1939
- }
1940
-
1941
- /// Bug 2 (0.4.2 P0): when expected_session_id is set (restart --allow-fresh
1942
- /// pre-allocates a UUID via --session-id) but the Claude CLI didn't adopt
1943
- /// it (no <expected>.jsonl on disk), capture must NOT fall back to
1944
- /// time-window narrowing over leader transcripts sharing the same cwd.
1945
- /// Pre-fix: leader transcript with later mtime would be silently picked
1946
- /// up as the worker session. Post-fix: empty list returned (caller treats
1947
- /// as session-not-yet-captured and retries on next tick).
1948
- #[test]
1949
- fn scan_expected_session_id_miss_refuses_to_pick_leader_sibling() {
1950
- let base = tmp_root("strict-no-leader-fallback");
1951
- let cwd = base.join("ws");
1952
- std::fs::create_dir_all(&cwd).unwrap();
1953
- let proj = base.join("projects");
1954
- // Simulate the leader's transcript and a stale earlier transcript in
1955
- // the same cwd. NEITHER matches expected_session_id.
1956
- let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
1957
- let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
1958
- let _ = (leader, stale);
1959
- let ctx = CaptureSessionContext {
1960
- agent_id: "claude-worker".to_string(),
1961
- spawn_cwd: cwd.clone(),
1962
- pane_id: None,
1963
- pane_pid: None,
1964
- spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
1965
- // Worker was spawned with --session-id <expected> but Claude
1966
- // didn't write <expected>.jsonl.
1967
- expected_session_id: Some(SessionId::new(
1968
- "99999999-9999-4999-8999-999999999999",
1969
- )),
1970
- provider_projects_root: Some(proj.clone()),
1971
- };
1972
- let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1973
- assert!(
1974
- out.is_empty(),
1975
- "expected_session_id set + no exact match → must NOT fall back to \
1976
- time-window narrowing (would grab leader's transcript). \
1977
- got={:?}",
1978
- out.iter()
1979
- .filter_map(|c| c.captured.session_id.as_ref().map(|s| s.as_str().to_string()))
1980
- .collect::<Vec<_>>()
1981
- );
1982
- let _ = std::fs::remove_dir_all(&base);
1983
- }
1984
-
1985
- // ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
1986
- struct HomeGuard {
1987
- prev: Option<std::ffi::OsString>,
1988
- }
1989
- impl HomeGuard {
1990
- fn set(home: &Path) -> Self {
1991
- let prev = std::env::var_os("HOME");
1992
- std::env::set_var("HOME", home);
1993
- Self { prev }
1994
- }
1995
- }
1996
- impl Drop for HomeGuard {
1997
- fn drop(&mut self) {
1998
- match &self.prev {
1999
- Some(v) => std::env::set_var("HOME", v),
2000
- None => std::env::remove_var("HOME"),
2001
- }
2002
- }
2003
- }
2004
-
2005
- /// 造一个 copilot session-store.db,写 (id, cwd, updated_at) 行。
2006
- fn seed_copilot_db(home: &Path, rows: &[(&str, &str, i64)]) {
2007
- let dir = home.join(".copilot");
2008
- std::fs::create_dir_all(&dir).unwrap();
2009
- let conn = rusqlite::Connection::open(dir.join("session-store.db")).unwrap();
2010
- conn.execute(
2011
- "create table sessions (id text primary key, cwd text, updated_at integer)",
2012
- [],
2013
- )
2014
- .unwrap();
2015
- for (id, cwd, updated) in rows {
2016
- conn.execute(
2017
- "insert into sessions (id, cwd, updated_at) values (?1, ?2, ?3)",
2018
- rusqlite::params![id, cwd, updated],
2019
- )
2020
- .unwrap();
2021
- }
2022
- }
2023
-
2024
- #[test]
2025
- #[serial_test::serial(env)]
2026
- fn copilot_expected_id_wins_over_leader_latest_same_cwd() {
2027
- // 真机复现的确定性 fixture:leader row updated 晚(latest),worker row id == expected。
2028
- // capture 必返 worker 自己的 id,不返 leader 的 latest。
2029
- let base = tmp_root("e11-copilot");
2030
- let home = base.join("home");
2031
- std::fs::create_dir_all(&home).unwrap();
2032
- let cwd = base.join("ws");
2033
- std::fs::create_dir_all(&cwd).unwrap();
2034
- let worker_id = "1142c4c2-0000-4000-8000-000000000001";
2035
- let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2036
- // leader updated_at 更大(latest-wins 会抓它);worker 更早。
2037
- seed_copilot_db(
2038
- &home,
2039
- &[
2040
- (worker_id, &cwd.to_string_lossy(), 100),
2041
- (leader_id, &cwd.to_string_lossy(), 999),
2042
- ],
2043
- );
2044
- let _h = HomeGuard::set(&home);
2045
- let ctx = CaptureSessionContext {
2046
- agent_id: "worker".to_string(),
2047
- spawn_cwd: cwd.clone(),
2048
- pane_id: None,
2049
- pane_pid: None,
2050
- spawned_at: None,
2051
- expected_session_id: Some(SessionId::new(worker_id)),
2052
- provider_projects_root: None,
2053
- };
2054
- let out = scan_copilot_session_store(&ctx);
2055
- assert_eq!(out.len(), 1, "expected-id point query → single authoritative candidate");
2056
- assert_eq!(
2057
- out[0].captured.session_id.as_ref().unwrap().as_str(),
2058
- worker_id,
2059
- "must return worker's own (expected) session, NOT leader's latest"
2060
- );
2061
- drop(_h);
2062
- let _ = std::fs::remove_dir_all(&base);
2063
- }
2064
-
2065
- #[test]
2066
- #[serial_test::serial(env)]
2067
- fn copilot_expected_id_absent_in_db_returns_empty_not_leader() {
2068
- // expected 设了但 db 无该 id(会话还没落)→ 返空(收敛重试),绝不回落抓 leader latest。
2069
- let base = tmp_root("e11-copilot-absent");
2070
- let home = base.join("home");
2071
- std::fs::create_dir_all(&home).unwrap();
2072
- let cwd = base.join("ws");
2073
- std::fs::create_dir_all(&cwd).unwrap();
2074
- let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2075
- seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
2076
- let _h = HomeGuard::set(&home);
2077
- let ctx = CaptureSessionContext {
2078
- agent_id: "worker".to_string(),
2079
- spawn_cwd: cwd.clone(),
2080
- pane_id: None,
2081
- pane_pid: None,
2082
- spawned_at: None,
2083
- expected_session_id: Some(SessionId::new("1142c4c2-0000-4000-8000-000000000001")),
2084
- provider_projects_root: None,
2085
- };
2086
- let out = scan_copilot_session_store(&ctx);
2087
- assert!(
2088
- out.is_empty(),
2089
- "expected id absent in db → empty (no promote, no leader latest); got {out:?}"
2090
- );
2091
- drop(_h);
2092
- let _ = std::fs::remove_dir_all(&base);
2093
- }
2094
-
2095
- #[test]
2096
- #[serial_test::serial(env)]
2097
- fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
2098
- // E11 层1 兜底洞(architect):无 expected + 同 cwd 仅 leader row → 必返空,绝不返 leader。
2099
- // (真实 copilot worker 恒有 expected,此分支不可达;保守返空堵住 allocator 不排除 leader 的洞。)
2100
- let base = tmp_root("e11-copilot-noexp");
2101
- let home = base.join("home");
2102
- std::fs::create_dir_all(&home).unwrap();
2103
- let cwd = base.join("ws");
2104
- std::fs::create_dir_all(&cwd).unwrap();
2105
- let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2106
- seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
2107
- let _h = HomeGuard::set(&home);
2108
- let ctx = CaptureSessionContext {
2109
- agent_id: "worker".to_string(),
2110
- spawn_cwd: cwd.clone(),
2111
- pane_id: None,
2112
- pane_pid: None,
2113
- spawned_at: None,
2114
- expected_session_id: None, // 无 expected → 兜底路径
2115
- provider_projects_root: None,
2116
- };
2117
- let out = scan_copilot_session_store(&ctx);
2118
- assert!(
2119
- out.is_empty(),
2120
- "no expected + only leader row in same cwd → must return empty, NOT leader; got {out:?}"
2121
- );
2122
- drop(_h);
2123
- let _ = std::fs::remove_dir_all(&base);
2124
- }
2125
- }