@team-agent/installer 0.3.6 → 0.3.7
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/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +63 -0
- package/crates/team-agent/src/cli/mod.rs +334 -35
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
- package/crates/team-agent/src/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/lifecycle/launch.rs +182 -47
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +204 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend.rs +14 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +82 -5
|
@@ -139,7 +139,20 @@ fn spawn_agents(
|
|
|
139
139
|
safety: &DangerousApproval,
|
|
140
140
|
transport: &dyn Transport,
|
|
141
141
|
) -> Result<Vec<StartedAgent>, LifecycleError> {
|
|
142
|
-
|
|
142
|
+
// E5 解耦:team_dir(角色定义 + profiles 所在)≠ spec_path.parent()(spec 已迁出到 .team/runtime)。
|
|
143
|
+
// 优先取 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
|
|
144
|
+
let team_dir_buf = crate::state::persist::load_runtime_state(workspace)
|
|
145
|
+
.ok()
|
|
146
|
+
.and_then(|state| {
|
|
147
|
+
state
|
|
148
|
+
.get("team_dir")
|
|
149
|
+
.and_then(serde_json::Value::as_str)
|
|
150
|
+
.filter(|s| !s.is_empty())
|
|
151
|
+
.map(PathBuf::from)
|
|
152
|
+
});
|
|
153
|
+
let team_dir = team_dir_buf
|
|
154
|
+
.as_deref()
|
|
155
|
+
.unwrap_or_else(|| spec_path.parent().unwrap_or_else(|| Path::new(".")));
|
|
143
156
|
let runtime_fast = matches!(
|
|
144
157
|
spec.get("runtime").and_then(|v| v.get("fast")),
|
|
145
158
|
Some(Value::Bool(true))
|
|
@@ -313,6 +326,28 @@ fn spawn_agents(
|
|
|
313
326
|
);
|
|
314
327
|
}
|
|
315
328
|
}
|
|
329
|
+
// E6 层1 实证3 + 诊断留痕:落最终 worker argv(spawn 前的真实形态)。
|
|
330
|
+
// 任何"--session-id 预定 UUID 没生效"必须能从 events.jsonl 回答:argv 里到底有没有它。
|
|
331
|
+
// 抽出 --session-id 值单列,方便和盘上 ~/.claude/projects/<cwd> 实际落的 UUID 对账。
|
|
332
|
+
{
|
|
333
|
+
let session_id_in_argv = plan
|
|
334
|
+
.argv
|
|
335
|
+
.iter()
|
|
336
|
+
.position(|a| a == "--session-id")
|
|
337
|
+
.and_then(|i| plan.argv.get(i + 1))
|
|
338
|
+
.cloned();
|
|
339
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
340
|
+
let _ = event_log.write(
|
|
341
|
+
"provider.worker.spawn_argv",
|
|
342
|
+
serde_json::json!({
|
|
343
|
+
"agent_id": agent_id_raw,
|
|
344
|
+
"provider": provider,
|
|
345
|
+
"argv": plan.argv,
|
|
346
|
+
"session_id_in_argv": session_id_in_argv,
|
|
347
|
+
"expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
316
351
|
let spawn = if started.is_empty() {
|
|
317
352
|
transport.spawn_first_with_env_unset(
|
|
318
353
|
session_name,
|
|
@@ -402,7 +437,14 @@ fn persist_spawn_agent_state(
|
|
|
402
437
|
.map(|agent| agent.agent_id.as_str().to_string())
|
|
403
438
|
.collect();
|
|
404
439
|
let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
|
|
405
|
-
|
|
440
|
+
// E5 解耦:profiles 随**角色定义**(team_dir),不随 spec(已迁出到 .team/runtime)。
|
|
441
|
+
// 优先 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
|
|
442
|
+
let profile_dir = state
|
|
443
|
+
.get("team_dir")
|
|
444
|
+
.and_then(serde_json::Value::as_str)
|
|
445
|
+
.filter(|s| !s.is_empty())
|
|
446
|
+
.map(|dir| Path::new(dir).join("profiles"))
|
|
447
|
+
.unwrap_or_else(|| spec_path.parent().unwrap_or(workspace).join("profiles"));
|
|
406
448
|
let mut agents = serde_json::Map::new();
|
|
407
449
|
let mut spawn_index = 0_u32;
|
|
408
450
|
for agent in spec_agent_values(spec) {
|
|
@@ -720,6 +762,34 @@ fn env_nonempty(key: &str) -> bool {
|
|
|
720
762
|
.is_some_and(|value| !value.is_empty())
|
|
721
763
|
}
|
|
722
764
|
|
|
765
|
+
/// B-7 / 036b — TEAM_AGENT_LEADER_PANE_ID 主动路径 fail-fast helper。
|
|
766
|
+
/// 入口形态(N38 三行式):
|
|
767
|
+
/// error : `TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: %<id>`
|
|
768
|
+
/// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
|
|
769
|
+
/// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
|
|
770
|
+
/// env 未设(或空)→ Ok(())。
|
|
771
|
+
/// env 设了但 transport.liveness(pane) 报 Dead → Err(RequirementUnmet)。
|
|
772
|
+
/// liveness 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead】fail-fast,
|
|
773
|
+
/// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
|
|
774
|
+
pub(crate) fn validate_active_leader_pane_env(
|
|
775
|
+
transport: &dyn Transport,
|
|
776
|
+
) -> Result<(), LifecycleError> {
|
|
777
|
+
let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
|
|
778
|
+
Ok(v) if !v.is_empty() => v,
|
|
779
|
+
_ => return Ok(()),
|
|
780
|
+
};
|
|
781
|
+
let probe = transport.liveness(&crate::transport::PaneId::new(&pane_id_raw));
|
|
782
|
+
let dead = matches!(probe, Ok(crate::transport::PaneLiveness::Dead));
|
|
783
|
+
if !dead {
|
|
784
|
+
return Ok(());
|
|
785
|
+
}
|
|
786
|
+
Err(LifecycleError::RequirementUnmet(format!(
|
|
787
|
+
"TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: {pane_id_raw}\n\
|
|
788
|
+
action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
|
|
789
|
+
log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
|
|
790
|
+
)))
|
|
791
|
+
}
|
|
792
|
+
|
|
723
793
|
fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
724
794
|
let Some(owner) = unbound_launched_owner(launched, launched_key) else {
|
|
725
795
|
return;
|
|
@@ -1809,6 +1879,12 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1809
1879
|
team_id: Option<&str>,
|
|
1810
1880
|
transport: &dyn Transport,
|
|
1811
1881
|
) -> Result<QuickStartReport, LifecycleError> {
|
|
1882
|
+
// B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
|
|
1883
|
+
// 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
|
|
1884
|
+
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
1885
|
+
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
1886
|
+
// 或修 env)/log(env var 名)。
|
|
1887
|
+
validate_active_leader_pane_env(transport)?;
|
|
1812
1888
|
if !agents_dir.exists() {
|
|
1813
1889
|
return Err(LifecycleError::Compile(format!(
|
|
1814
1890
|
"agents dir not found: {}",
|
|
@@ -1869,13 +1945,14 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1869
1945
|
override_spec_session_name(&mut spec, &format!("team-{requested}"));
|
|
1870
1946
|
}
|
|
1871
1947
|
let session_name = spec_session_name(&spec);
|
|
1948
|
+
// team_key 身份源 = team_dir(agents_dir).name(角色定义目录),不依赖 spec 落点。
|
|
1872
1949
|
let state_team_key = explicit_team_key.clone().unwrap_or_else(|| {
|
|
1873
|
-
|
|
1874
|
-
runtime_team_key_for_spec(&spec_path, &spec, &session_name)
|
|
1950
|
+
runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
|
|
1875
1951
|
});
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1952
|
+
// E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
|
|
1953
|
+
// Bug2:原子写(tmp+rename),避免半截 spec。
|
|
1954
|
+
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
|
|
1955
|
+
write_spec_atomic(&spec_path, &spec)?;
|
|
1879
1956
|
let _store = crate::message_store::MessageStore::open(&workspace)
|
|
1880
1957
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
1881
1958
|
let resolved_spec_path =
|
|
@@ -2268,9 +2345,8 @@ pub fn add_agent(
|
|
|
2268
2345
|
}
|
|
2269
2346
|
Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
|
|
2270
2347
|
};
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
})?;
|
|
2348
|
+
// E5 §3:compile_team 要角色定义目录(team_dir),不是 spec 落点(spec_workspace=runtime)。
|
|
2349
|
+
let team_dir = selected.team_dir;
|
|
2274
2350
|
add_agent_with_transport_at_paths(
|
|
2275
2351
|
&selected.run_workspace,
|
|
2276
2352
|
&team_dir,
|
|
@@ -2336,21 +2412,40 @@ fn add_agent_with_transport_at_paths(
|
|
|
2336
2412
|
"agent id already exists: {agent_id}"
|
|
2337
2413
|
)));
|
|
2338
2414
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2415
|
+
// E5 Bug1:不再 copy role 文件进 <team_dir>/agents(自拷贝 O_TRUNC 截断反模式)。
|
|
2416
|
+
// 就地读外部 role 文档编译,注入 base team spec 的 agents/routing。role 文件留在原处。
|
|
2417
|
+
let mut spec = crate::compiler::compile_team(team_dir)
|
|
2418
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2419
|
+
let workspace_s = spec
|
|
2420
|
+
.get("team")
|
|
2421
|
+
.and_then(|team| team.get("workspace"))
|
|
2422
|
+
.and_then(Value::as_str)
|
|
2423
|
+
.unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
|
|
2424
|
+
.to_string();
|
|
2425
|
+
let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
|
|
2426
|
+
.map(|(meta, _)| meta)
|
|
2427
|
+
.unwrap_or(Value::Null);
|
|
2428
|
+
let compiled = crate::compiler::compile_role_agent(role_file_path, &team_meta, &workspace_s)
|
|
2341
2429
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2430
|
+
if compiled.id != agent_id.as_str() {
|
|
2431
|
+
return Err(LifecycleError::Compile(format!(
|
|
2432
|
+
"role file declares name '{}' but add-agent id is '{}'",
|
|
2433
|
+
compiled.id, agent_id
|
|
2434
|
+
)));
|
|
2435
|
+
}
|
|
2436
|
+
inject_agent_into_spec(&mut spec, compiled.agent, &compiled.id)?;
|
|
2342
2437
|
let safety = effective_runtime_config(&spec)?;
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
let (meta, _) = crate::compiler::read_front_matter(
|
|
2438
|
+
// E5 spec 迁移:重编译的 spec 原子写到 .team/runtime/<team_key>/(不落用户目录 team_dir)。
|
|
2439
|
+
let spec_path = crate::model::paths::runtime_spec_path(run_workspace, &canonical_team_key);
|
|
2440
|
+
write_spec_atomic(&spec_path, &spec)?;
|
|
2441
|
+
let (meta, _) = crate::compiler::read_front_matter(role_file_path)
|
|
2347
2442
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2348
2443
|
upsert_agent_state_from_role(
|
|
2349
2444
|
run_workspace,
|
|
2350
2445
|
&canonical_team_key,
|
|
2351
2446
|
agent_id,
|
|
2352
2447
|
&meta,
|
|
2353
|
-
|
|
2448
|
+
role_file_path,
|
|
2354
2449
|
&safety,
|
|
2355
2450
|
)?;
|
|
2356
2451
|
let started = crate::lifecycle::restart::start_agent_at_paths(
|
|
@@ -2457,26 +2552,39 @@ fn upsert_agent_state_from_role(
|
|
|
2457
2552
|
save_launched_team_state_for_key(workspace, &state, Some(canonical_team_key))
|
|
2458
2553
|
}
|
|
2459
2554
|
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
LifecycleError::
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
))
|
|
2478
|
-
|
|
2479
|
-
|
|
2555
|
+
/// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
|
|
2556
|
+
/// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
|
|
2557
|
+
fn inject_agent_into_spec(
|
|
2558
|
+
spec: &mut Value,
|
|
2559
|
+
agent: Value,
|
|
2560
|
+
agent_id: &str,
|
|
2561
|
+
) -> Result<(), LifecycleError> {
|
|
2562
|
+
let Value::Map(pairs) = spec else {
|
|
2563
|
+
return Err(LifecycleError::Compile("spec is not a map".to_string()));
|
|
2564
|
+
};
|
|
2565
|
+
// agents 列表追加。
|
|
2566
|
+
match pairs.iter_mut().find(|(k, _)| k == "agents") {
|
|
2567
|
+
Some((_, Value::List(agents))) => agents.push(agent),
|
|
2568
|
+
_ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
|
|
2569
|
+
}
|
|
2570
|
+
// routing.rules 追加 route-<id>(与 compile_team 同形)。
|
|
2571
|
+
if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
|
|
2572
|
+
if let Some((_, Value::List(rules))) = routing.iter_mut().find(|(k, _)| k == "rules") {
|
|
2573
|
+
rules.push(Value::Map(vec![
|
|
2574
|
+
("id".to_string(), Value::Str(format!("route-{agent_id}"))),
|
|
2575
|
+
(
|
|
2576
|
+
"match".to_string(),
|
|
2577
|
+
Value::Map(vec![(
|
|
2578
|
+
"assignee".to_string(),
|
|
2579
|
+
Value::List(vec![Value::Str(agent_id.to_string())]),
|
|
2580
|
+
)]),
|
|
2581
|
+
),
|
|
2582
|
+
("assign_to".to_string(), Value::Str(agent_id.to_string())),
|
|
2583
|
+
("priority".to_string(), Value::Int(10)),
|
|
2584
|
+
]));
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
Ok(())
|
|
2480
2588
|
}
|
|
2481
2589
|
|
|
2482
2590
|
/// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
|
|
@@ -2523,15 +2631,18 @@ pub fn fork_agent_with_transport(
|
|
|
2523
2631
|
crate::state::selector::SelectorMode::RequireSpec,
|
|
2524
2632
|
)
|
|
2525
2633
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
2526
|
-
|
|
2527
|
-
|
|
2634
|
+
// E5 §3:team_dir(角色定义+profiles)恒用户目录。spec 读用 selector 解析的 spec_path
|
|
2635
|
+
// (读序 B:runtime 优先、legacy 回落),写恒走 runtime_spec_path(canonical 落点)。
|
|
2636
|
+
let fork_team_dir = selected.team_dir.clone();
|
|
2637
|
+
let read_spec_path = selected.spec_path.clone().ok_or_else(|| {
|
|
2638
|
+
LifecycleError::TeamSelect("active team spec not found".to_string())
|
|
2528
2639
|
})?;
|
|
2529
2640
|
let workspace = selected.run_workspace;
|
|
2530
2641
|
let state = selected.state;
|
|
2531
2642
|
ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
|
|
2532
|
-
let spec_path =
|
|
2533
|
-
let text = std::fs::read_to_string(&
|
|
2534
|
-
.map_err(|e| LifecycleError::Compile(format!("{}: {e}",
|
|
2643
|
+
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &selected.team_key);
|
|
2644
|
+
let text = std::fs::read_to_string(&read_spec_path)
|
|
2645
|
+
.map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
|
|
2535
2646
|
let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2536
2647
|
if find_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
|
|
2537
2648
|
return Err(LifecycleError::RequirementUnmet(format!(
|
|
@@ -2571,10 +2682,12 @@ pub fn fork_agent_with_transport(
|
|
|
2571
2682
|
)));
|
|
2572
2683
|
}
|
|
2573
2684
|
let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id, label)?;
|
|
2574
|
-
|
|
2685
|
+
// validate 用角色定义目录的 team_workspace(校验 working_directory),非 spec 落点。
|
|
2686
|
+
let validate_ws = crate::model::paths::team_workspace(&fork_team_dir)
|
|
2687
|
+
.unwrap_or_else(|_| workspace.clone());
|
|
2688
|
+
crate::model::spec::validate_spec(&new_spec, &validate_ws)
|
|
2575
2689
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2576
|
-
|
|
2577
|
-
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
|
|
2690
|
+
write_spec_atomic(&spec_path, &new_spec)?;
|
|
2578
2691
|
let new_agent = find_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
|
|
2579
2692
|
LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
|
|
2580
2693
|
})?;
|
|
@@ -2630,7 +2743,8 @@ pub fn fork_agent_with_transport(
|
|
|
2630
2743
|
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
2631
2744
|
e
|
|
2632
2745
|
})?;
|
|
2633
|
-
|
|
2746
|
+
// E5 §3:profiles 随角色定义目录(team_dir),不随已迁出的 spec。
|
|
2747
|
+
let profile_dir = fork_team_dir.join("profiles");
|
|
2634
2748
|
let profile_launch =
|
|
2635
2749
|
crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
|
|
2636
2750
|
&workspace,
|
|
@@ -3314,7 +3428,28 @@ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
|
|
|
3314
3428
|
/// `runtime` map and/or the `session_name` entry if absent. Used by quick-start to
|
|
3315
3429
|
/// derive the tmux session from the REQUESTED team identity (CR-040/042) rather
|
|
3316
3430
|
/// than the template's compiled-in name.
|
|
3317
|
-
|
|
3431
|
+
/// E5 Bug2(atomic 真修):原子写 runtime spec —— 写 `<spec>.tmp-<pid>` 再 rename 覆盖,
|
|
3432
|
+
/// 避免崩溃/并发留下半截 spec(plain fs::write 会 in-place truncate 后逐字节写)。
|
|
3433
|
+
/// rename 失败时清理 tmp,原 spec(若有)不动。
|
|
3434
|
+
pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), LifecycleError> {
|
|
3435
|
+
if let Some(parent) = spec_path.parent() {
|
|
3436
|
+
std::fs::create_dir_all(parent)
|
|
3437
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
|
|
3438
|
+
}
|
|
3439
|
+
let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
|
|
3440
|
+
std::fs::write(&tmp, yaml::dumps(spec))
|
|
3441
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
|
|
3442
|
+
if let Err(e) = std::fs::rename(&tmp, spec_path) {
|
|
3443
|
+
let _ = std::fs::remove_file(&tmp);
|
|
3444
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
3445
|
+
"{}: {e}",
|
|
3446
|
+
spec_path.display()
|
|
3447
|
+
)));
|
|
3448
|
+
}
|
|
3449
|
+
Ok(())
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
|
|
3318
3453
|
let Value::Map(root) = spec else { return };
|
|
3319
3454
|
let runtime_slot = root
|
|
3320
3455
|
.iter_mut()
|
|
@@ -331,15 +331,10 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
|
|
|
331
331
|
.get("status")
|
|
332
332
|
.and_then(|value| value.as_str())
|
|
333
333
|
.is_some_and(|status| status == "running");
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
let has_interaction_marker = agent
|
|
339
|
-
.get("first_send_at")
|
|
340
|
-
.and_then(|value| value.as_str())
|
|
341
|
-
.is_some_and(|value| !value.is_empty());
|
|
342
|
-
missing_session_id && is_running && (has_live_pane_binding || has_interaction_marker)
|
|
334
|
+
// E6 层2 (C2): required-missing 谓词只看 session_id 有无 + 是否在跑。
|
|
335
|
+
// pane 绑定 / first_send_at 在 gate 时刻天然可空(自启动 worker leader 从未发消息),
|
|
336
|
+
// 不能作判据 —— 否则真丢上下文的 null-session worker 被漏判,走静默 fresh。
|
|
337
|
+
missing_session_id && is_running
|
|
343
338
|
})
|
|
344
339
|
.collect::<Vec<_>>();
|
|
345
340
|
missing.sort();
|
|
@@ -101,10 +101,15 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
101
101
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
102
102
|
let mut state = selected.state;
|
|
103
103
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
104
|
-
|
|
104
|
+
// E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
|
|
105
|
+
// 角色定义=第一真相源。角色齐 → compile_team 重建 + 保留运行期 override(session_name)+
|
|
106
|
+
// 写 runtime spec。角色缺(TEAM.md/agents 不在)→ 显式拒(列缺哪些),旧 spec 原地保留不删不用。
|
|
107
|
+
let spec = rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
|
|
108
|
+
// 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
|
|
109
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
|
|
110
|
+
let spec_workspace = runtime_spec.parent().ok_or_else(|| {
|
|
105
111
|
LifecycleError::TeamSelect("active team spec workspace not found".to_string())
|
|
106
112
|
})?;
|
|
107
|
-
let spec = load_team_spec(spec_workspace)?;
|
|
108
113
|
let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
|
|
109
114
|
let mut convergence = converge_missing_provider_sessions(
|
|
110
115
|
&mut state,
|
|
@@ -713,6 +718,74 @@ fn restart_candidate_from_state(
|
|
|
713
718
|
}
|
|
714
719
|
}
|
|
715
720
|
|
|
721
|
+
/// E5 task#3 / RC-A6a:每次 restart 都以**角色定义**(team_dir 的 TEAM.md+agents/*.md)
|
|
722
|
+
/// compile_team 重建 runtime spec(覆盖),保留运行期 override(session_name 必须延续,
|
|
723
|
+
/// 否则 tmux session 对不上)。写到 .team/runtime/<team_key>/team.spec.yaml。
|
|
724
|
+
///
|
|
725
|
+
/// 角色定义缺(team_dir 未记 / TEAM.md 不在 / agents 不在)→ **显式拒**(LifecycleError,
|
|
726
|
+
/// CLI N38 三行式),列出缺哪些;**旧 spec 原地保留不删不用**(T2 防数据销毁,无静默路径)。
|
|
727
|
+
fn rebuild_runtime_spec_from_roles(
|
|
728
|
+
run_workspace: &Path,
|
|
729
|
+
team_key: &str,
|
|
730
|
+
state: &serde_json::Value,
|
|
731
|
+
) -> Result<YamlValue, LifecycleError> {
|
|
732
|
+
// team_dir(角色定义源)优先取 state.team_dir;缺则回落 run_workspace(自含 team-dir 布局,
|
|
733
|
+
// run_workspace 本身即角色目录)。两者都无角色定义则下面的齐全性检查会显式拒。
|
|
734
|
+
let team_dir = state
|
|
735
|
+
.get("team_dir")
|
|
736
|
+
.and_then(serde_json::Value::as_str)
|
|
737
|
+
.filter(|s| !s.is_empty())
|
|
738
|
+
.map(std::path::PathBuf::from)
|
|
739
|
+
.unwrap_or_else(|| run_workspace.to_path_buf());
|
|
740
|
+
// 角色定义齐全性检查(显式拒,列缺哪些;旧 spec 不动)。
|
|
741
|
+
let mut missing: Vec<String> = Vec::new();
|
|
742
|
+
if !team_dir.join("TEAM.md").exists() {
|
|
743
|
+
missing.push(format!("{}/TEAM.md", team_dir.display()));
|
|
744
|
+
}
|
|
745
|
+
let agents_dir = team_dir.join("agents");
|
|
746
|
+
let has_role_doc = std::fs::read_dir(&agents_dir)
|
|
747
|
+
.map(|entries| {
|
|
748
|
+
entries.flatten().any(|e| {
|
|
749
|
+
e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
|
750
|
+
})
|
|
751
|
+
})
|
|
752
|
+
.unwrap_or(false);
|
|
753
|
+
if !has_role_doc {
|
|
754
|
+
missing.push(format!("{}/*.md (at least one role doc)", agents_dir.display()));
|
|
755
|
+
}
|
|
756
|
+
if !missing.is_empty() {
|
|
757
|
+
// N38 三行式:error / action / log。旧 runtime spec 原地保留(不删不用)。
|
|
758
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
759
|
+
"cannot restart: role definitions missing for team '{team_key}': {}. \
|
|
760
|
+
action: restore the listed role docs (TEAM.md + agents/*.md are the source of truth), \
|
|
761
|
+
then re-run restart; the previous runtime spec is left in place (not used). \
|
|
762
|
+
log: team_dir={}",
|
|
763
|
+
missing.join(", "),
|
|
764
|
+
team_dir.display(),
|
|
765
|
+
)));
|
|
766
|
+
}
|
|
767
|
+
// 重建:compile_team(角色定义) + 保留运行期 session_name override。
|
|
768
|
+
let mut spec = crate::compiler::compile_team(&team_dir)
|
|
769
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
770
|
+
if let Some(session_name) = state
|
|
771
|
+
.get("session_name")
|
|
772
|
+
.and_then(serde_json::Value::as_str)
|
|
773
|
+
.filter(|s| !s.is_empty())
|
|
774
|
+
{
|
|
775
|
+
crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
|
|
776
|
+
}
|
|
777
|
+
// 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
|
|
778
|
+
let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
|
|
779
|
+
crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
|
|
780
|
+
// RC-A6a:重建成功后清理用户目录的 legacy spec(中间产物不该留在角色目录)。
|
|
781
|
+
// 仅删 team_dir 下的 team.spec.yaml(角色定义 TEAM.md/agents 不动);失败不致命(best-effort)。
|
|
782
|
+
let legacy_spec = team_dir.join("team.spec.yaml");
|
|
783
|
+
if legacy_spec.exists() && legacy_spec != spec_path {
|
|
784
|
+
let _ = std::fs::remove_file(&legacy_spec);
|
|
785
|
+
}
|
|
786
|
+
Ok(spec)
|
|
787
|
+
}
|
|
788
|
+
|
|
716
789
|
fn restart_candidate_spec_path(workspace: &Path, state: &serde_json::Value) -> std::path::PathBuf {
|
|
717
790
|
if let Some(path) = state
|
|
718
791
|
.get("spec_path")
|
|
@@ -120,8 +120,8 @@ pub fn python_type_name(value: &serde_json::Value) -> &'static str {
|
|
|
120
120
|
/// `_collect_corrupt_first_send_at`,`orchestration.py:430/467`)。读 fixture state 的
|
|
121
121
|
/// `agents.<id>`,对每非 paused worker:
|
|
122
122
|
/// (1) corrupt first_send_at → 收进 `corrupt_entries`(carry python type-name);
|
|
123
|
-
/// (2) 算 resume 决策(`
|
|
124
|
-
///
|
|
123
|
+
/// (2) 算 resume 决策(`session_id→Resume` / `null session && allow_fresh→FreshStart` /
|
|
124
|
+
/// 否则 `Refuse`;E6 层2:null session 不再因 first_send_at=null 静默 fresh);
|
|
125
125
|
/// (3) `Refuse` 的 worker(reason=`no_persisted_session_id`(无 session)|`session_unresumable`)
|
|
126
126
|
/// 进 `unresumable`。
|
|
127
127
|
/// restart() **先**调它再 teardown;corrupt 非空 → `RefusedInvalidFirstSendAt`,unresumable
|
|
@@ -171,10 +171,12 @@ pub fn classify_restart_plan(
|
|
|
171
171
|
.and_then(|v| v.as_str())
|
|
172
172
|
.filter(|s| !s.is_empty())
|
|
173
173
|
.map(SessionId::new);
|
|
174
|
-
|
|
174
|
+
// E6 层2 (C2, 用户裁定"绝不静默 fresh"): null session 只有显式 --allow-fresh 才 fresh,
|
|
175
|
+
// 否则 Refuse(→ resume_not_ready + 指引)。删 `!interacted` 短路 —— 自启动 worker
|
|
176
|
+
// (leader 从未发消息 → first_send_at=null → interacted=false)会被它静默 fresh 丢上下文。
|
|
175
177
|
let decision = if session_id.is_some() {
|
|
176
178
|
ResumeDecision::Resume
|
|
177
|
-
} else if
|
|
179
|
+
} else if allow_fresh {
|
|
178
180
|
ResumeDecision::FreshStart
|
|
179
181
|
} else {
|
|
180
182
|
ResumeDecision::Refuse
|
|
@@ -491,14 +491,57 @@ fn classify_restart_plan_interacted_unresumable_with_allow_fresh_yields_fresh_st
|
|
|
491
491
|
}
|
|
492
492
|
|
|
493
493
|
#[test]
|
|
494
|
-
fn
|
|
495
|
-
//
|
|
494
|
+
fn classify_restart_plan_never_interacted_null_session_refuses_not_fresh() {
|
|
495
|
+
// E6 层2 (C2, 用户裁定"绝不静默 fresh"): first_send_at absent(自启动 worker,leader 从未发消息)
|
|
496
|
+
// + session_id null → 不能再静默 FreshStart(那会丢真实 provider 会话上下文)。
|
|
497
|
+
// 默认 !allow_fresh → Refuse + unresumable(诚实出口 resume_not_ready)。
|
|
496
498
|
let state = json!({
|
|
497
499
|
"agents": { "w1": { "provider": "claude", "session_id": null } }
|
|
498
500
|
});
|
|
499
501
|
let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
|
|
500
502
|
assert_eq!(plan.decisions.len(), 1);
|
|
501
|
-
assert_eq!(
|
|
503
|
+
assert_eq!(
|
|
504
|
+
plan.decisions[0].decision,
|
|
505
|
+
ResumeDecision::Refuse,
|
|
506
|
+
"null-session 自启动 worker 默认必须 Refuse,不许静默 fresh"
|
|
507
|
+
);
|
|
508
|
+
assert_eq!(plan.unresumable.len(), 1, "Refuse 必入 unresumable(诚实出口)");
|
|
509
|
+
assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
|
|
514
|
+
// E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
|
|
515
|
+
let state = json!({
|
|
516
|
+
"agents": { "w1": { "provider": "claude", "session_id": null } }
|
|
517
|
+
});
|
|
518
|
+
let plan = classify_restart_plan(&state, true).expect("纯验证不应 Err");
|
|
519
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
520
|
+
assert_eq!(
|
|
521
|
+
plan.decisions[0].decision,
|
|
522
|
+
ResumeDecision::FreshStart,
|
|
523
|
+
"显式 --allow-fresh 才允许 null-session worker fresh"
|
|
524
|
+
);
|
|
525
|
+
assert!(
|
|
526
|
+
plan.unresumable.is_empty(),
|
|
527
|
+
"allow_fresh 下不触发 unresumable refusal"
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#[test]
|
|
532
|
+
fn classify_restart_plan_codex_with_session_still_resumes() {
|
|
533
|
+
// E6 层2 回归锁(不误伤): codex worker first_send_at=null 但 session_id 已捕 →
|
|
534
|
+
// 仍走 Resume(分流轴是 session_id 有无,不是 interacted)。防层2 修法把 has_session 也误判。
|
|
535
|
+
let state = json!({
|
|
536
|
+
"agents": { "w1": { "provider": "codex", "session_id": "sess-codex-abc" } }
|
|
537
|
+
});
|
|
538
|
+
let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
|
|
539
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
540
|
+
assert_eq!(
|
|
541
|
+
plan.decisions[0].decision,
|
|
542
|
+
ResumeDecision::Resume,
|
|
543
|
+
"有 session_id 必 Resume,与 first_send_at/interacted 无关"
|
|
544
|
+
);
|
|
502
545
|
assert!(plan.unresumable.is_empty());
|
|
503
546
|
}
|
|
504
547
|
|