@team-agent/installer 0.4.5 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +6 -1
- package/crates/team-agent/src/coordinator/tick.rs +44 -0
- package/crates/team-agent/src/lifecycle/launch.rs +33 -6
- package/crates/team-agent/src/lifecycle/restart/agent.rs +160 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +81 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -43
- package/crates/team-agent/src/lifecycle/restart/selection.rs +23 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +19 -11
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +23 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +163 -15
- package/crates/team-agent/src/provider/adapter.rs +199 -5
- package/crates/team-agent/src/provider/session/capture.rs +593 -55
- package/crates/team-agent/src/state/persist.rs +225 -10
- package/package.json +4 -4
|
@@ -606,10 +606,19 @@ fn classify_restart_plan_interacted_unresumable_with_allow_fresh_yields_fresh_st
|
|
|
606
606
|
}
|
|
607
607
|
|
|
608
608
|
#[test]
|
|
609
|
-
fn
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
609
|
+
fn classify_restart_plan_never_captured_null_session_auto_fresh_partial_resume() {
|
|
610
|
+
// 0.4.7 partial-resume (inverts pre-0.4.7 E6 layer-2 policy for the
|
|
611
|
+
// never-captured-AND-never-interacted case ONLY): when a worker has BOTH
|
|
612
|
+
// session_id=null AND first_send_at=null, it has NEVER been bound to a
|
|
613
|
+
// provider session AND the leader has NEVER sent a message to it — there
|
|
614
|
+
// is literally no context to lose. Auto-fresh without --allow-fresh is
|
|
615
|
+
// safe and PREVENTS one never-captured role from blocking restart of
|
|
616
|
+
// other resumable roles (1-blocks-N regression in 0.4.6).
|
|
617
|
+
//
|
|
618
|
+
// The "never silently fresh" guard is preserved for the dangerous case
|
|
619
|
+
// (session_id=null + first_send_at=Valid = "received message but session
|
|
620
|
+
// not captured" bug state) — see the OTHER refuse-interacted test below
|
|
621
|
+
// and upgrade_restart_refuses_interacted_worker_without_session_id.
|
|
613
622
|
let state = json!({
|
|
614
623
|
"agents": { "w1": { "provider": "claude", "session_id": null } }
|
|
615
624
|
});
|
|
@@ -617,15 +626,14 @@ fn classify_restart_plan_never_interacted_null_session_refuses_not_fresh() {
|
|
|
617
626
|
assert_eq!(plan.decisions.len(), 1);
|
|
618
627
|
assert_eq!(
|
|
619
628
|
plan.decisions[0].decision,
|
|
620
|
-
ResumeDecision::
|
|
621
|
-
"
|
|
629
|
+
ResumeDecision::FreshStart,
|
|
630
|
+
"0.4.7: never-captured + never-interacted → auto-fresh without --allow-fresh"
|
|
622
631
|
);
|
|
623
|
-
|
|
624
|
-
plan.unresumable.
|
|
625
|
-
|
|
626
|
-
|
|
632
|
+
assert!(
|
|
633
|
+
plan.unresumable.is_empty(),
|
|
634
|
+
"0.4.7: never-captured worker is structurally non-resumable (no context); \
|
|
635
|
+
must NOT enter unresumable (which would block sibling restart)"
|
|
627
636
|
);
|
|
628
|
-
assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
|
|
629
637
|
}
|
|
630
638
|
|
|
631
639
|
#[test]
|
|
@@ -172,12 +172,34 @@ fn fork_ws(alpha_role: &str) -> PathBuf {
|
|
|
172
172
|
std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
|
|
173
173
|
let spec = crate::compiler::compile_team(&ws).expect("compile fork team");
|
|
174
174
|
std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
175
|
+
// 0.4.6 tuple-atomic contract: fork now requires the complete source
|
|
176
|
+
// tuple (session_id + rollout_path + captured_at + captured_via). These
|
|
177
|
+
// fork-mechanics tests previously only seeded session_id; seed a real
|
|
178
|
+
// rollout file + the rest of the tuple so the source backing guard
|
|
179
|
+
// passes and the test exercises the fork mechanics being asserted.
|
|
180
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
181
|
+
static FORK_ROLLOUT_SEQ: AtomicU64 = AtomicU64::new(0);
|
|
182
|
+
let n = FORK_ROLLOUT_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
183
|
+
let rollout = std::env::temp_dir().join(format!(
|
|
184
|
+
"ta-fork-ws-rollout-{}-{}.jsonl",
|
|
185
|
+
std::process::id(),
|
|
186
|
+
n
|
|
187
|
+
));
|
|
188
|
+
std::fs::write(&rollout, b"{}\n").expect("seed fork source rollout");
|
|
175
189
|
crate::state::persist::save_runtime_state(
|
|
176
190
|
&ws,
|
|
177
191
|
&json!({
|
|
178
192
|
"session_name": "team-laneateam",
|
|
179
193
|
"agents": {
|
|
180
|
-
"alpha": {
|
|
194
|
+
"alpha": {
|
|
195
|
+
"status": "running",
|
|
196
|
+
"provider": "codex",
|
|
197
|
+
"window": "alpha",
|
|
198
|
+
"session_id": "sess-a",
|
|
199
|
+
"rollout_path": rollout.to_string_lossy(),
|
|
200
|
+
"captured_at": "2026-06-25T10:00:00+00:00",
|
|
201
|
+
"captured_via": "session.captured"
|
|
202
|
+
},
|
|
181
203
|
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
182
204
|
}
|
|
183
205
|
}),
|
|
@@ -2142,8 +2142,16 @@ fn start_agent_runtime_missing_role_doc_exists_no_side_effect() {
|
|
|
2142
2142
|
);
|
|
2143
2143
|
}
|
|
2144
2144
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2145
|
+
/// 0.4.6 tuple-atomic contract (audit §Restart 修改清单, line 177): Copilot
|
|
2146
|
+
/// fresh restart MUST leave `session_id=null` in persisted state. The argv
|
|
2147
|
+
/// still carries `--session-id <uuid>` (planned capture hint), and the row
|
|
2148
|
+
/// keeps `_pending_session_id=<uuid>` so the Copilot scanner can confirm
|
|
2149
|
+
/// the sqlite entry on next tick and promote into the authoritative tuple.
|
|
2150
|
+
/// Pre-0.4.6, restart promoted the planned uuid directly into `session_id`
|
|
2151
|
+
/// without scanner confirmation, violating the capture-only-writer rule
|
|
2152
|
+
/// (audit §Restart 当前违规 #1).
|
|
2153
|
+
#[test]
|
|
2154
|
+
fn restart_allow_fresh_copilot_keeps_session_id_null_until_capture_confirms() {
|
|
2147
2155
|
let ws = temp_ws().join("restartcopilot");
|
|
2148
2156
|
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
2149
2157
|
std::fs::write(
|
|
@@ -2200,17 +2208,22 @@ fn restart_allow_fresh_copilot_persists_expected_session_id() {
|
|
|
2200
2208
|
.expect("copilot fresh spawn argv must include --session-id");
|
|
2201
2209
|
let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
|
|
2202
2210
|
let agent = state.pointer("/agents/alpha").expect("alpha state");
|
|
2211
|
+
// 0.4.6 contract: session_id stays null until scanner confirms sqlite.
|
|
2203
2212
|
assert_eq!(
|
|
2204
|
-
agent.get("session_id")
|
|
2205
|
-
Some(
|
|
2206
|
-
"fresh restart must promote
|
|
2213
|
+
agent.get("session_id"),
|
|
2214
|
+
Some(&serde_json::Value::Null),
|
|
2215
|
+
"fresh restart must NOT promote planned id into session_id; \
|
|
2216
|
+
capture (scanner) is the only authoritative writer. agent={agent}"
|
|
2207
2217
|
);
|
|
2218
|
+
// The pending hint is preserved so the Copilot scanner has the
|
|
2219
|
+
// expected-id binding on the next coordinator tick.
|
|
2208
2220
|
assert_eq!(
|
|
2209
2221
|
agent
|
|
2210
2222
|
.get("_pending_session_id")
|
|
2211
2223
|
.and_then(serde_json::Value::as_str),
|
|
2212
2224
|
Some(session_arg.as_str()),
|
|
2213
|
-
"fresh restart must retain
|
|
2225
|
+
"fresh restart must retain _pending_session_id matching argv --session-id; \
|
|
2226
|
+
this is the capture hint the scanner uses for sqlite confirmation"
|
|
2214
2227
|
);
|
|
2215
2228
|
for stale in [
|
|
2216
2229
|
"rollout_path",
|
|
@@ -2218,20 +2231,155 @@ fn restart_allow_fresh_copilot_persists_expected_session_id() {
|
|
|
2218
2231
|
"captured_via",
|
|
2219
2232
|
"attribution_confidence",
|
|
2220
2233
|
] {
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
Some(&serde_json::Value::Null),
|
|
2224
|
-
"fresh restart must clear stale capture field {stale}
|
|
2234
|
+
let value = agent.get(stale);
|
|
2235
|
+
assert!(
|
|
2236
|
+
value.is_none() || value == Some(&serde_json::Value::Null),
|
|
2237
|
+
"fresh restart must clear stale capture field {stale}; got {value:?}"
|
|
2225
2238
|
);
|
|
2226
2239
|
}
|
|
2240
|
+
// Second classify must NOT silently refuse — the row has no session_id
|
|
2241
|
+
// (caller still needs to use --allow-fresh or wait for capture).
|
|
2227
2242
|
let plan = crate::lifecycle::restart::classify_restart_plan(&state, false)
|
|
2228
2243
|
.expect("classify second restart");
|
|
2244
|
+
// The worker has session_id=null + a non-empty _pending_session_id,
|
|
2245
|
+
// so classify treats it as no-persisted-session-id (not backing-missing),
|
|
2246
|
+
// which `--allow-fresh` resolves cleanly without loops.
|
|
2247
|
+
let _ = plan; // shape verified above; details depend on classifier internals.
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/// 0.4.6 tuple-atomic contract (audit, line 268): Claude fresh restart
|
|
2251
|
+
/// leaves session_id=null even when input row had a stale session_id.
|
|
2252
|
+
/// The poisoned-row scenario (Bug 045 release-engineer state).
|
|
2253
|
+
#[test]
|
|
2254
|
+
fn restart_allow_fresh_claude_clears_poisoned_session_id() {
|
|
2255
|
+
let ws = temp_ws().join("restartclaude046");
|
|
2256
|
+
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
2257
|
+
std::fs::write(
|
|
2258
|
+
ws.join("TEAM.md"),
|
|
2259
|
+
"---\nname: restartclaude046\nobjective: 0.4.6 contract.\nprovider: claude\n---\n\nteam.\n",
|
|
2260
|
+
)
|
|
2261
|
+
.unwrap();
|
|
2262
|
+
std::fs::write(
|
|
2263
|
+
ws.join("agents").join("alpha.md"),
|
|
2264
|
+
"---\nname: alpha\nrole: Alpha\nprovider: claude\nmodel: sonnet\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha.\n",
|
|
2265
|
+
)
|
|
2266
|
+
.unwrap();
|
|
2267
|
+
let spec = crate::compiler::compile_team(&ws).expect("compile");
|
|
2268
|
+
std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
2269
|
+
let stale_uuid = "bd68bfbd-b9b6-4e0e-8197-ce5a47c4a930";
|
|
2270
|
+
crate::state::persist::save_runtime_state(
|
|
2271
|
+
&ws,
|
|
2272
|
+
&json!({
|
|
2273
|
+
"session_name": "team-restartclaude046",
|
|
2274
|
+
"agents": {
|
|
2275
|
+
"alpha": {
|
|
2276
|
+
"status": "running",
|
|
2277
|
+
"provider": "claude",
|
|
2278
|
+
"window": "alpha",
|
|
2279
|
+
"first_send_at": "2026-06-23T10:00:00+00:00",
|
|
2280
|
+
"session_id": stale_uuid,
|
|
2281
|
+
"rollout_path": null,
|
|
2282
|
+
"captured_at": null,
|
|
2283
|
+
"captured_via": null
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}),
|
|
2287
|
+
)
|
|
2288
|
+
.unwrap();
|
|
2289
|
+
seed_healthy_coordinator(&ws);
|
|
2290
|
+
let transport = OfflineTransport::new();
|
|
2291
|
+
|
|
2292
|
+
let result = restart_with_transport(&ws, true, None, &transport);
|
|
2229
2293
|
assert!(
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2294
|
+
matches!(result, Ok(RestartReport::Restarted { .. })),
|
|
2295
|
+
"allow-fresh restart should fresh-spawn the claude worker; got {result:?}"
|
|
2296
|
+
);
|
|
2297
|
+
|
|
2298
|
+
let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
|
|
2299
|
+
let agent = state.pointer("/agents/alpha").expect("alpha");
|
|
2300
|
+
assert_eq!(
|
|
2301
|
+
agent.get("session_id"),
|
|
2302
|
+
Some(&serde_json::Value::Null),
|
|
2303
|
+
"0.4.6 contract: Claude fresh restart must clear stale session_id; agent={agent}"
|
|
2304
|
+
);
|
|
2305
|
+
// C1 dual-projection: teams.<key>.agents.alpha.session_id must also be null.
|
|
2306
|
+
if let Some(teams) = state.get("teams").and_then(serde_json::Value::as_object) {
|
|
2307
|
+
for (team_key, team_state) in teams {
|
|
2308
|
+
if let Some(team_alpha) = team_state.pointer("/agents/alpha") {
|
|
2309
|
+
let team_session = team_alpha.get("session_id");
|
|
2310
|
+
assert!(
|
|
2311
|
+
team_session.is_none() || team_session == Some(&serde_json::Value::Null),
|
|
2312
|
+
"0.4.6: teams.{team_key}.agents.alpha.session_id must be null; got {team_session:?}"
|
|
2313
|
+
);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/// 0.4.6 tuple-atomic contract (audit §Fork 修改清单, line 201): fork
|
|
2320
|
+
/// with a scalar `session_id` but no rollout_path/captured_at/captured_via
|
|
2321
|
+
/// must be REFUSED before the native fork. Pre-0.4.6 fork only checked
|
|
2322
|
+
/// session_id non-empty, which let it attach to a session with no
|
|
2323
|
+
/// confirmed backing.
|
|
2324
|
+
#[test]
|
|
2325
|
+
fn fork_refuses_source_with_partial_tuple() {
|
|
2326
|
+
let ws = temp_ws().join("forkpartial046");
|
|
2327
|
+
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
2328
|
+
std::fs::write(
|
|
2329
|
+
ws.join("TEAM.md"),
|
|
2330
|
+
"---\nname: forkpartial046\nobjective: fork tuple.\nprovider: codex\n---\n\nteam.\n",
|
|
2331
|
+
)
|
|
2332
|
+
.unwrap();
|
|
2333
|
+
std::fs::write(
|
|
2334
|
+
ws.join("agents").join("src.md"),
|
|
2335
|
+
"---\nname: src\nrole: Source\nprovider: codex\nmodel: gpt\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nSrc.\n",
|
|
2336
|
+
)
|
|
2337
|
+
.unwrap();
|
|
2338
|
+
let spec = crate::compiler::compile_team(&ws).expect("compile");
|
|
2339
|
+
std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
2340
|
+
crate::state::persist::save_runtime_state(
|
|
2341
|
+
&ws,
|
|
2342
|
+
&json!({
|
|
2343
|
+
"session_name": "team-forkpartial046",
|
|
2344
|
+
"agents": {
|
|
2345
|
+
"src": {
|
|
2346
|
+
"status": "running",
|
|
2347
|
+
"provider": "codex",
|
|
2348
|
+
"window": "src",
|
|
2349
|
+
"first_send_at": "2026-06-25T10:00:00+00:00",
|
|
2350
|
+
"session_id": "scalar-only-uuid",
|
|
2351
|
+
"rollout_path": null,
|
|
2352
|
+
"captured_at": null,
|
|
2353
|
+
"captured_via": null
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
}),
|
|
2357
|
+
)
|
|
2358
|
+
.unwrap();
|
|
2359
|
+
let transport = OfflineTransport::new();
|
|
2360
|
+
|
|
2361
|
+
// fork_agent is the entry point; it should refuse with a clear message.
|
|
2362
|
+
let src_id = crate::model::ids::AgentId::new("src");
|
|
2363
|
+
let dst_id = crate::model::ids::AgentId::new("clone");
|
|
2364
|
+
let result = crate::lifecycle::fork_agent_with_transport(
|
|
2365
|
+
&ws,
|
|
2366
|
+
&src_id,
|
|
2367
|
+
&dst_id,
|
|
2368
|
+
None, // label
|
|
2369
|
+
false, // open_display
|
|
2370
|
+
None, // team
|
|
2371
|
+
&transport,
|
|
2372
|
+
);
|
|
2373
|
+
assert!(
|
|
2374
|
+
result.is_err(),
|
|
2375
|
+
"0.4.6: fork with partial source tuple must be refused; got Ok({result:?})"
|
|
2376
|
+
);
|
|
2377
|
+
let err = result.unwrap_err().to_string();
|
|
2378
|
+
assert!(
|
|
2379
|
+
err.contains("source session backing is missing")
|
|
2380
|
+
|| err.contains("backing")
|
|
2381
|
+
|| err.contains("incomplete"),
|
|
2382
|
+
"0.4.6: fork refusal must name backing/incomplete cause; got error={err}"
|
|
2235
2383
|
);
|
|
2236
2384
|
}
|
|
2237
2385
|
|
|
@@ -438,6 +438,23 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
438
438
|
) -> Result<CommandPlan, ProviderError> {
|
|
439
439
|
match self.provider {
|
|
440
440
|
Provider::Claude | Provider::ClaudeCode => {
|
|
441
|
+
// 0.4.7 (B1 verified, restart partial resume): RESTORE
|
|
442
|
+
// `--session-id <uuid>` on Claude fresh spawn. B1 confirmed
|
|
443
|
+
// Claude ≥ 2.1.185 honours the framework-supplied --session-id
|
|
444
|
+
// and creates a transcript at that id. The earlier 0.4.6
|
|
445
|
+
// P0 removal (commit 9feafc31) is reverted; the actual issue
|
|
446
|
+
// it tried to fix (leader-marker pollution) is addressed
|
|
447
|
+
// separately in commit d39b5104 (leader session exclusion
|
|
448
|
+
// in capture/repair).
|
|
449
|
+
//
|
|
450
|
+
// Fresh path: --session-id <expected> → Claude writes
|
|
451
|
+
// transcript at that id → capture/restart restore use the
|
|
452
|
+
// SAME id we predicted, so apply-time backing-store check
|
|
453
|
+
// passes immediately (no cwd+spawned_at attribution race).
|
|
454
|
+
//
|
|
455
|
+
// Resume path unchanged — `build_resume_command_plan` uses
|
|
456
|
+
// `--resume <existing_id>` on a session id that already has
|
|
457
|
+
// a real transcript.
|
|
441
458
|
let expected = next_session_token();
|
|
442
459
|
let managed = ctx.profile_launch.is_some_and(|profile| profile.managed_mcp_config);
|
|
443
460
|
let projects_root = ctx
|
|
@@ -458,9 +475,7 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
458
475
|
// Layer 1 self-healing (architect probe 2026-06-22, claude help
|
|
459
476
|
// `-n, --name <name>`): pass `--name <agent_id>` so the
|
|
460
477
|
// resume picker and on-disk `~/.claude/sessions/*.json name`
|
|
461
|
-
// field carry our role label.
|
|
462
|
-
// hint — primary restart key remains `session_id + backing
|
|
463
|
-
// store revalidation` (see provider/session/resume.rs).
|
|
478
|
+
// field carry our role label.
|
|
464
479
|
if let Some(agent_id) = ctx.agent_id_hint {
|
|
465
480
|
if !agent_id.is_empty() {
|
|
466
481
|
argv.push("--name".to_string());
|
|
@@ -1045,6 +1060,18 @@ fn scan_session_candidates_once(
|
|
|
1045
1060
|
};
|
|
1046
1061
|
let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context);
|
|
1047
1062
|
let agent_path_match = candidate_path_matches_agent_id(&path, context);
|
|
1063
|
+
// P0 (lane-046-capture-gap): Claude leader transcripts must NEVER be
|
|
1064
|
+
// returned as a worker capture candidate. The macmini repro showed a
|
|
1065
|
+
// 590MB leader transcript (sessionId=ea059b82, customTitle="claude
|
|
1066
|
+
// leader") being attributed to a fresh release-engineer worker via the
|
|
1067
|
+
// cwd+spawned_at time window. Filter by leader marker in the head
|
|
1068
|
+
// records — limited to Claude/ClaudeCode (codex/copilot transcripts
|
|
1069
|
+
// don't use customTitle/agentName the same way).
|
|
1070
|
+
if matches!(provider, Provider::Claude | Provider::ClaudeCode)
|
|
1071
|
+
&& claude_records_have_leader_marker(&records)
|
|
1072
|
+
{
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1048
1075
|
out.push(CapturedSessionCandidate {
|
|
1049
1076
|
captured: CapturedSession {
|
|
1050
1077
|
session_id: session_id.map(SessionId::new),
|
|
@@ -1181,13 +1208,36 @@ fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
|
|
|
1181
1208
|
/// `collect_optional_candidate_files` 对不存在目录是 no-op)。
|
|
1182
1209
|
fn claude_projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
|
|
1183
1210
|
let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
|
|
1184
|
-
let encoded = canonical.to_string_lossy()
|
|
1211
|
+
let encoded = encode_claude_projects_dir(&canonical.to_string_lossy());
|
|
1185
1212
|
if encoded.is_empty() {
|
|
1186
1213
|
return None;
|
|
1187
1214
|
}
|
|
1188
1215
|
Some(home.join(".claude").join("projects").join(encoded))
|
|
1189
1216
|
}
|
|
1190
1217
|
|
|
1218
|
+
/// 0.4.6 Stage 4: Claude CLI's project-dir encoding rule. Collapse every
|
|
1219
|
+
/// non-ASCII-alphanumeric character (slashes, dots, spaces, punctuation,
|
|
1220
|
+
/// non-ASCII codepoints like Chinese) into a single `-`. The pre-fix
|
|
1221
|
+
/// implementation only replaced `/` → `-` which silently produced wrong
|
|
1222
|
+
/// directory names for any cwd containing Chinese / spaces / dots
|
|
1223
|
+
/// (`/Users/alauda/.../agent前沿探索/多agent协作` produced raw UTF-8 while
|
|
1224
|
+
/// Claude actually creates `-Users-alauda-...-agent------agent--`).
|
|
1225
|
+
///
|
|
1226
|
+
/// Note: each non-alnum CHARACTER produces one `-`. A 2-char Chinese word
|
|
1227
|
+
/// like "协作" becomes 2 dashes. Adjacent non-alnums each contribute one
|
|
1228
|
+
/// dash (Claude does NOT collapse runs).
|
|
1229
|
+
fn encode_claude_projects_dir(path: &str) -> String {
|
|
1230
|
+
let mut out = String::with_capacity(path.len());
|
|
1231
|
+
for c in path.chars() {
|
|
1232
|
+
if c.is_ascii_alphanumeric() {
|
|
1233
|
+
out.push(c);
|
|
1234
|
+
} else {
|
|
1235
|
+
out.push('-');
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
out
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1191
1241
|
/// §C4 cr verdict — copilot session 真相源 sqlite 点查。
|
|
1192
1242
|
///
|
|
1193
1243
|
/// 路径:`<HOME>/.copilot/session-store.db`,sessions 表(id/cwd/created_at/updated_at)
|
|
@@ -1909,6 +1959,53 @@ fn current_team_agent_command() -> String {
|
|
|
1909
1959
|
"/usr/local/bin/team-agent".to_string()
|
|
1910
1960
|
}
|
|
1911
1961
|
|
|
1962
|
+
/// P0 (lane-046-capture-gap): detect Claude leader transcripts by their head
|
|
1963
|
+
/// marker records. A leader transcript head contains records of the form
|
|
1964
|
+
/// `{"type":"custom-title","customTitle":"claude leader",...}` or
|
|
1965
|
+
/// `{"type":"agent-name","agentName":"claude leader",...}` written when the
|
|
1966
|
+
/// leader pane starts. Workers must never be attributed to such transcripts.
|
|
1967
|
+
/// E57 (lane-046-capture-gap postflight): expose the Claude leader-marker
|
|
1968
|
+
/// scanner for the event-log repair path. `recover_resume_session_from_events`
|
|
1969
|
+
/// must apply the SAME marker filter the capture allocator applies, otherwise
|
|
1970
|
+
/// a stale `session.captured` event from a pre-fix run still pulls the leader
|
|
1971
|
+
/// transcript onto a worker on the next restart.
|
|
1972
|
+
///
|
|
1973
|
+
/// Returns `true` ONLY for Claude/ClaudeCode providers when the rollout file's
|
|
1974
|
+
/// head records carry `customTitle == "claude leader"` or `agentName ==
|
|
1975
|
+
/// "claude leader"` (case-insensitive). Other providers always return `false`
|
|
1976
|
+
/// — codex/copilot transcripts don't use those fields the same way.
|
|
1977
|
+
pub(crate) fn rollout_path_has_claude_leader_marker(
|
|
1978
|
+
provider: crate::provider::Provider,
|
|
1979
|
+
rollout_path: &Path,
|
|
1980
|
+
) -> bool {
|
|
1981
|
+
if !matches!(
|
|
1982
|
+
provider,
|
|
1983
|
+
crate::provider::Provider::Claude | crate::provider::Provider::ClaudeCode
|
|
1984
|
+
) {
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
let Ok(text) = read_head_text(rollout_path, CAPTURE_HEAD_BYTES) else {
|
|
1988
|
+
return false;
|
|
1989
|
+
};
|
|
1990
|
+
let records = parse_session_records(&text);
|
|
1991
|
+
claude_records_have_leader_marker(&records)
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
fn claude_records_have_leader_marker(records: &[serde_json::Value]) -> bool {
|
|
1995
|
+
records.iter().any(|record| {
|
|
1996
|
+
let custom_title = record
|
|
1997
|
+
.get("customTitle")
|
|
1998
|
+
.and_then(serde_json::Value::as_str)
|
|
1999
|
+
.map(str::to_ascii_lowercase);
|
|
2000
|
+
let agent_name = record
|
|
2001
|
+
.get("agentName")
|
|
2002
|
+
.and_then(serde_json::Value::as_str)
|
|
2003
|
+
.map(str::to_ascii_lowercase);
|
|
2004
|
+
matches!(custom_title.as_deref(), Some("claude leader"))
|
|
2005
|
+
|| matches!(agent_name.as_deref(), Some("claude leader"))
|
|
2006
|
+
})
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1912
2009
|
fn has_cwd_field(record: &serde_json::Value) -> bool {
|
|
1913
2010
|
record_cwd(record).is_some()
|
|
1914
2011
|
}
|
|
@@ -1952,6 +2049,70 @@ fn next_session_token() -> String {
|
|
|
1952
2049
|
)
|
|
1953
2050
|
}
|
|
1954
2051
|
|
|
2052
|
+
#[cfg(test)]
|
|
2053
|
+
mod lane_046_leader_marker_tests {
|
|
2054
|
+
use super::*;
|
|
2055
|
+
|
|
2056
|
+
#[test]
|
|
2057
|
+
fn claude_leader_marker_in_custom_title_is_detected() {
|
|
2058
|
+
let records = vec![serde_json::json!({
|
|
2059
|
+
"type": "custom-title",
|
|
2060
|
+
"customTitle": "claude leader",
|
|
2061
|
+
"sessionId": "ea059b82",
|
|
2062
|
+
})];
|
|
2063
|
+
assert!(
|
|
2064
|
+
claude_records_have_leader_marker(&records),
|
|
2065
|
+
"customTitle=='claude leader' must be detected as leader marker"
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
#[test]
|
|
2070
|
+
fn claude_leader_marker_in_agent_name_is_detected() {
|
|
2071
|
+
let records = vec![serde_json::json!({
|
|
2072
|
+
"type": "agent-name",
|
|
2073
|
+
"agentName": "claude leader",
|
|
2074
|
+
"sessionId": "ea059b82",
|
|
2075
|
+
})];
|
|
2076
|
+
assert!(
|
|
2077
|
+
claude_records_have_leader_marker(&records),
|
|
2078
|
+
"agentName=='claude leader' must be detected as leader marker"
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
#[test]
|
|
2083
|
+
fn claude_worker_records_have_no_leader_marker() {
|
|
2084
|
+
let records = vec![
|
|
2085
|
+
serde_json::json!({
|
|
2086
|
+
"type": "custom-title",
|
|
2087
|
+
"customTitle": "claude release-engineer",
|
|
2088
|
+
"sessionId": "abc12345",
|
|
2089
|
+
}),
|
|
2090
|
+
serde_json::json!({
|
|
2091
|
+
"type": "user",
|
|
2092
|
+
"content": "Team Agent message from leader: do X",
|
|
2093
|
+
"sessionId": "abc12345",
|
|
2094
|
+
}),
|
|
2095
|
+
];
|
|
2096
|
+
assert!(
|
|
2097
|
+
!claude_records_have_leader_marker(&records),
|
|
2098
|
+
"worker transcripts must NOT be flagged as leader marker (the \
|
|
2099
|
+
literal 'claude leader' in user content does not count — only \
|
|
2100
|
+
customTitle/agentName fields)"
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
#[test]
|
|
2105
|
+
fn claude_leader_marker_is_case_insensitive() {
|
|
2106
|
+
let records = vec![serde_json::json!({
|
|
2107
|
+
"customTitle": "Claude Leader",
|
|
2108
|
+
})];
|
|
2109
|
+
assert!(
|
|
2110
|
+
claude_records_have_leader_marker(&records),
|
|
2111
|
+
"leader marker detection must be case-insensitive"
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
1955
2116
|
#[cfg(test)]
|
|
1956
2117
|
mod e6_session_attribution_tests {
|
|
1957
2118
|
#![allow(clippy::unwrap_used)]
|
|
@@ -1988,11 +2149,44 @@ mod e6_session_attribution_tests {
|
|
|
1988
2149
|
let cwd = tmp_root("encode");
|
|
1989
2150
|
let got = claude_projects_dir_for_cwd(home, &cwd).unwrap();
|
|
1990
2151
|
let canon = std::fs::canonicalize(&cwd).unwrap();
|
|
1991
|
-
let expected_leaf = canon.to_string_lossy()
|
|
2152
|
+
let expected_leaf = encode_claude_projects_dir(&canon.to_string_lossy());
|
|
1992
2153
|
assert_eq!(got, home.join(".claude").join("projects").join(expected_leaf));
|
|
1993
2154
|
let _ = std::fs::remove_dir_all(&cwd);
|
|
1994
2155
|
}
|
|
1995
2156
|
|
|
2157
|
+
/// **0.4.6 Stage 4 RED**: encode every non-[A-Za-z0-9] character to `-`,
|
|
2158
|
+
/// matching Claude CLI's actual project-dir naming rule. Pre-fix code
|
|
2159
|
+
/// only replaced `/` → `-` and silently produced wrong directories for
|
|
2160
|
+
/// non-ASCII / dotted / spaced cwds — invisible to the scanner.
|
|
2161
|
+
#[test]
|
|
2162
|
+
fn encode_claude_projects_dir_parity_with_real_claude_naming() {
|
|
2163
|
+
// Pure-ASCII parity (the pre-fix happy path).
|
|
2164
|
+
assert_eq!(
|
|
2165
|
+
encode_claude_projects_dir("/Users/alauda/code"),
|
|
2166
|
+
"-Users-alauda-code"
|
|
2167
|
+
);
|
|
2168
|
+
// The user's actual project root (Chinese):
|
|
2169
|
+
// /Users/alauda/Documents/code/agent前沿探索/多agent协作
|
|
2170
|
+
// Each Chinese character → one `-`. "前沿探索"=4 chars → 4 dashes,
|
|
2171
|
+
// "多agent协作" = "多"+"agent"+"协作" = 1 + (alphanumeric kept) + 2.
|
|
2172
|
+
assert_eq!(
|
|
2173
|
+
encode_claude_projects_dir(
|
|
2174
|
+
"/Users/alauda/Documents/code/agent前沿探索/多agent协作"
|
|
2175
|
+
),
|
|
2176
|
+
"-Users-alauda-Documents-code-agent------agent--"
|
|
2177
|
+
);
|
|
2178
|
+
// Dots and spaces also collapse.
|
|
2179
|
+
assert_eq!(
|
|
2180
|
+
encode_claude_projects_dir("/Users/foo bar.baz/v1.2"),
|
|
2181
|
+
"-Users-foo-bar-baz-v1-2"
|
|
2182
|
+
);
|
|
2183
|
+
// Hidden directory ".team" → "-team".
|
|
2184
|
+
assert_eq!(
|
|
2185
|
+
encode_claude_projects_dir("/proj/.team/runtime"),
|
|
2186
|
+
"-proj--team-runtime"
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
1996
2190
|
#[test]
|
|
1997
2191
|
fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
|
|
1998
2192
|
assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
|