@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +98 -5
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/cli/named_address.rs +864 -0
- package/crates/team-agent/src/cli/send.rs +104 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +4 -0
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- 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::
|
|
6
|
+
use super::helpers::patterns;
|
|
7
7
|
use super::types::{
|
|
8
|
-
AuthHintStatus,
|
|
9
|
-
|
|
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
|
-
// `
|
|
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(
|
|
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
|
-
}
|