@team-agent/installer 0.4.2 → 0.4.4
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/lifecycle/launch.rs +10 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +114 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +1 -1
- package/crates/team-agent/src/provider/adapter.rs +90 -0
- package/crates/team-agent/src/provider/session/capture.rs +16 -5
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -4850,6 +4850,16 @@ fn spec_agents(spec: &Value) -> Vec<AgentId> {
|
|
|
4850
4850
|
.collect()
|
|
4851
4851
|
}
|
|
4852
4852
|
|
|
4853
|
+
/// Bug 1 (0.4.2): expose spec agent id set so the restart path can filter
|
|
4854
|
+
/// state.agents to only the agents currently defined in the rebuilt spec.
|
|
4855
|
+
/// Returns a `BTreeSet<String>` for O(log n) membership checks.
|
|
4856
|
+
pub(crate) fn spec_agent_id_set(spec: &Value) -> std::collections::BTreeSet<String> {
|
|
4857
|
+
spec_agent_values(spec)
|
|
4858
|
+
.into_iter()
|
|
4859
|
+
.filter_map(|agent| agent.get("id").and_then(Value::as_str).map(str::to_string))
|
|
4860
|
+
.collect()
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4853
4863
|
fn spec_agent_values(spec: &Value) -> Vec<&Value> {
|
|
4854
4864
|
spec.get("agents")
|
|
4855
4865
|
.and_then(Value::as_list)
|
|
@@ -142,6 +142,41 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
142
142
|
LifecycleError::TeamSelect("active team spec workspace not found".to_string())
|
|
143
143
|
})?;
|
|
144
144
|
let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
|
|
145
|
+
// Bug 1 (0.4.2 P0): the rebuilt spec is the single source of truth for the
|
|
146
|
+
// active roster. Any agent in state.agents that is NOT in the rebuilt
|
|
147
|
+
// spec is a stale leftover (role doc was deleted between sessions) and
|
|
148
|
+
// must NOT be restarted — otherwise a `team-agent restart` would re-spawn
|
|
149
|
+
// a removed worker from stale state. Prune state.agents in-place before
|
|
150
|
+
// convergence/plan/spawn so every downstream step (resume validation,
|
|
151
|
+
// event log, spawn loop) sees the bounded roster.
|
|
152
|
+
let spec_agent_ids = crate::lifecycle::launch::spec_agent_id_set(&spec);
|
|
153
|
+
if let Some(agents_obj) = state.get_mut("agents").and_then(|v| v.as_object_mut()) {
|
|
154
|
+
let stale_ids: Vec<String> = agents_obj
|
|
155
|
+
.keys()
|
|
156
|
+
.filter(|id| !spec_agent_ids.contains(id.as_str()))
|
|
157
|
+
.cloned()
|
|
158
|
+
.collect();
|
|
159
|
+
for stale_id in &stale_ids {
|
|
160
|
+
agents_obj.remove(stale_id);
|
|
161
|
+
let _ = crate::event_log::EventLog::new(&selected.run_workspace).write(
|
|
162
|
+
"restart.agent_skipped_not_in_spec",
|
|
163
|
+
serde_json::json!({
|
|
164
|
+
"agent_id": stale_id,
|
|
165
|
+
"team_key": selected.team_key.as_str(),
|
|
166
|
+
"reason": "agent present in state.agents but absent from rebuilt spec; \
|
|
167
|
+
role doc was removed — restart will not respawn removed workers",
|
|
168
|
+
"action": format!(
|
|
169
|
+
"to permanently remove run `team-agent remove-agent {stale_id}`; \
|
|
170
|
+
to re-add restore the role doc under agents/<id>.md and run \
|
|
171
|
+
`team-agent restart`"
|
|
172
|
+
),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if !stale_ids.is_empty() {
|
|
177
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
145
180
|
let mut convergence = converge_missing_provider_sessions(
|
|
146
181
|
&mut state,
|
|
147
182
|
session_convergence_deadline(session_converge_deadline_ms),
|
|
@@ -619,6 +654,13 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
619
654
|
&failed_agents,
|
|
620
655
|
"partial",
|
|
621
656
|
)?;
|
|
657
|
+
// 0.3.30 Bug 1: auto-attach on partial restart too — workers that did
|
|
658
|
+
// come up still need a leader_receiver pane to deliver report_result.
|
|
659
|
+
try_autobind_leader_after_restart(
|
|
660
|
+
&selected.run_workspace,
|
|
661
|
+
Some(selected.team_key.as_str()),
|
|
662
|
+
&state,
|
|
663
|
+
);
|
|
622
664
|
return Ok(RestartReport::Partial {
|
|
623
665
|
session_name,
|
|
624
666
|
agents: successful_agents,
|
|
@@ -634,6 +676,16 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
634
676
|
&failed_agents,
|
|
635
677
|
"ok",
|
|
636
678
|
)?;
|
|
679
|
+
// 0.3.30 Bug 1: auto-attach leader from caller's TMUX_PANE if available.
|
|
680
|
+
// Mirrors quick-start's seed_launched_owner_from_env behaviour: a restart
|
|
681
|
+
// invoked from a tmux pane should bind that pane as leader_receiver,
|
|
682
|
+
// restoring the worker→leader delivery path. Failure is non-fatal — the
|
|
683
|
+
// user can still run `team-agent attach-leader` manually.
|
|
684
|
+
try_autobind_leader_after_restart(
|
|
685
|
+
&selected.run_workspace,
|
|
686
|
+
Some(&selected.team_key),
|
|
687
|
+
&state,
|
|
688
|
+
);
|
|
637
689
|
// 0.3.28 Step 1: topology invariant guard (warn-only). Same pattern as
|
|
638
690
|
// `lifecycle::launch::launch_with_transport_in_workspace` — logs to stderr,
|
|
639
691
|
// never panics. Hard error path is deferred to Step 10.
|
|
@@ -1028,6 +1080,68 @@ fn verify_spawned_agent_live(
|
|
|
1028
1080
|
Ok(())
|
|
1029
1081
|
}
|
|
1030
1082
|
|
|
1083
|
+
/// 0.3.30 Bug 1: restart success path auto-attach.
|
|
1084
|
+
/// When `TMUX_PANE` is present in the caller's env, treat the restart as if
|
|
1085
|
+
/// the user had also run `attach-leader` from that pane. Mirrors quick-start's
|
|
1086
|
+
/// `seed_launched_owner_from_env` semantics.
|
|
1087
|
+
///
|
|
1088
|
+
/// Failure modes are intentionally non-fatal — `attach_leader` returns Err if
|
|
1089
|
+
/// the pane validation rejects (e.g. caller pane is a registered worker pane,
|
|
1090
|
+
/// E51 guard). In that case the user must still run `attach-leader` manually,
|
|
1091
|
+
/// matching pre-fix behaviour. We log to stderr so the operator sees why
|
|
1092
|
+
/// auto-attach didn't take.
|
|
1093
|
+
fn try_autobind_leader_after_restart(
|
|
1094
|
+
workspace: &std::path::Path,
|
|
1095
|
+
team: Option<&str>,
|
|
1096
|
+
state: &serde_json::Value,
|
|
1097
|
+
) {
|
|
1098
|
+
if std::env::var_os("TMUX_PANE").is_none() {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
// Provider: prefer the existing team_owner.provider (if rebind),
|
|
1102
|
+
// else leader_receiver.provider (stale, but still informative),
|
|
1103
|
+
// else default to ClaudeCode (matches the most common deployment).
|
|
1104
|
+
let provider = state
|
|
1105
|
+
.pointer("/team_owner/provider")
|
|
1106
|
+
.and_then(serde_json::Value::as_str)
|
|
1107
|
+
.or_else(|| {
|
|
1108
|
+
state
|
|
1109
|
+
.pointer("/leader_receiver/provider")
|
|
1110
|
+
.and_then(serde_json::Value::as_str)
|
|
1111
|
+
})
|
|
1112
|
+
.and_then(|s| match s {
|
|
1113
|
+
"codex" => Some(crate::model::enums::Provider::Codex),
|
|
1114
|
+
"claude" | "claude_code" | "claude-code" => {
|
|
1115
|
+
Some(crate::model::enums::Provider::ClaudeCode)
|
|
1116
|
+
}
|
|
1117
|
+
"copilot" => Some(crate::model::enums::Provider::Copilot),
|
|
1118
|
+
_ => None,
|
|
1119
|
+
})
|
|
1120
|
+
.unwrap_or(crate::model::enums::Provider::ClaudeCode);
|
|
1121
|
+
let team_str = team;
|
|
1122
|
+
match crate::leader::attach_leader(workspace, team_str, None, provider) {
|
|
1123
|
+
Ok(result) if result.ok => {
|
|
1124
|
+
eprintln!(
|
|
1125
|
+
"team_agent::restart auto_attach_leader ok pane={:?} team={:?}",
|
|
1126
|
+
result.bound_pane_id.as_ref().map(|p| p.as_str()),
|
|
1127
|
+
team_str,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
Ok(result) => {
|
|
1131
|
+
eprintln!(
|
|
1132
|
+
"team_agent::restart auto_attach_leader skipped reason={:?} team={:?}",
|
|
1133
|
+
result.reason, team_str,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
Err(error) => {
|
|
1137
|
+
eprintln!(
|
|
1138
|
+
"team_agent::restart auto_attach_leader failed error={error} team={team_str:?} \
|
|
1139
|
+
(run `team-agent attach-leader` from your tmux pane to bind manually)",
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1031
1145
|
fn mark_leader_receiver_rebind_required(state: &mut serde_json::Value, session_name: &SessionName) {
|
|
1032
1146
|
let Some(receiver) = state
|
|
1033
1147
|
.get_mut("leader_receiver")
|
|
@@ -2648,7 +2648,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
2648
2648
|
"spawned_at",
|
|
2649
2649
|
"pane_id",
|
|
2650
2650
|
],
|
|
2651
|
-
"0.3.
|
|
2651
|
+
"0.3.31 Codex capture correction: no framework _pending_session_id for Codex (CLI doesn't honor --session-id); raw={raw}"
|
|
2652
2652
|
);
|
|
2653
2653
|
assert_eq!(agent["status"], json!("running"));
|
|
2654
2654
|
assert_eq!(agent["provider"], json!("codex"));
|
|
@@ -476,6 +476,14 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
476
476
|
}
|
|
477
477
|
// codex.py:105-118 — the profile command overrides (codex_profile / codex_config)
|
|
478
478
|
// ride on `agent["_provider_profile"]`, which only the plan path carries.
|
|
479
|
+
//
|
|
480
|
+
// 0.3.31 Codex capture correction (reverts ad518f8): Codex CLI does
|
|
481
|
+
// NOT accept `--session-id`, so a framework-generated UUID is never
|
|
482
|
+
// matched against Codex's own session_meta.payload.id. Setting
|
|
483
|
+
// expected_session_id caused the apply-time Stage 1 guard to
|
|
484
|
+
// permanently reject the real Codex transcript. Codex capture must
|
|
485
|
+
// anchor on (cwd, spawned_at) instead — handled in
|
|
486
|
+
// `scan_session_candidates_once` below.
|
|
479
487
|
Provider::Codex => Ok(CommandPlan::argv_only(codex_base_command(
|
|
480
488
|
None,
|
|
481
489
|
ctx.auth_mode,
|
|
@@ -1049,6 +1057,38 @@ fn scan_session_candidates_once(
|
|
|
1049
1057
|
agent_path_match,
|
|
1050
1058
|
});
|
|
1051
1059
|
}
|
|
1060
|
+
// 0.3.31 Codex capture correction: HARD (cwd, spawned_at) filter.
|
|
1061
|
+
// Codex CLI does NOT honor `--session-id` so we cannot use
|
|
1062
|
+
// expected_session_id semantics. The only safe identity boundary is:
|
|
1063
|
+
// * session_meta.payload.cwd == spawn_cwd (already filtered above via
|
|
1064
|
+
// requires_cwd_match), AND
|
|
1065
|
+
// * session_meta.payload.timestamp >= spawned_at - small_grace, OR file
|
|
1066
|
+
// mtime >= spawned_at - small_grace (the candidate must be POST-spawn).
|
|
1067
|
+
// Candidates older than the current spawn are pre-reset / pre-restart
|
|
1068
|
+
// remnants and MUST be dropped, not merely de-prioritized — otherwise the
|
|
1069
|
+
// single-candidate allocator path picks them, and the Stage 1 mismatch
|
|
1070
|
+
// guard later rejects them, producing the 0.4.4 attribution_ambiguous loop.
|
|
1071
|
+
if matches!(provider, Provider::Codex) {
|
|
1072
|
+
if let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) {
|
|
1073
|
+
// 5-second grace: allow for clock skew between Codex's session
|
|
1074
|
+
// timestamp and our spawned_at (Codex records LOCAL time before
|
|
1075
|
+
// RFC3339-encoding, framework records UTC; small skew possible
|
|
1076
|
+
// across midnight or DST boundary).
|
|
1077
|
+
let grace = std::time::Duration::from_secs(5);
|
|
1078
|
+
let cutoff = spawned_at.checked_sub(grace).unwrap_or(spawned_at);
|
|
1079
|
+
out.retain(|candidate| {
|
|
1080
|
+
let path = match candidate.captured.rollout_path.as_ref() {
|
|
1081
|
+
Some(p) => p.as_path(),
|
|
1082
|
+
None => return false,
|
|
1083
|
+
};
|
|
1084
|
+
std::fs::metadata(path)
|
|
1085
|
+
.and_then(|meta| meta.modified())
|
|
1086
|
+
.map(|mtime| mtime >= cutoff)
|
|
1087
|
+
.unwrap_or(false)
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1052
1092
|
// E6 层1·C(机会性兜底):若盘上真有 expected_session_id 命名的 transcript(claude 哪天
|
|
1053
1093
|
// 真采用 --session-id,或别的 provider 本就采用),直接唯一命中,省去时间窗扫描。
|
|
1054
1094
|
// 命不中(交互式 claude 现实:不落 <expected>.jsonl)→ 回落 B。
|
|
@@ -1072,6 +1112,12 @@ fn scan_session_candidates_once(
|
|
|
1072
1112
|
// identity match (TEAM_AGENT_ID literal in the transcript head, or
|
|
1073
1113
|
// path-encoded agent_id). If none, return empty — capture stays
|
|
1074
1114
|
// pending/ambiguous, which is safer than misattribution.
|
|
1115
|
+
//
|
|
1116
|
+
// Bug 2 (0.4.2 P0) reinforcement: even when positive-identity
|
|
1117
|
+
// filtering returns no candidates, do NOT let downstream
|
|
1118
|
+
// time-window narrowing run for Claude when expected_session_id
|
|
1119
|
+
// misses — that's exactly the leader-session-leak symptom the
|
|
1120
|
+
// architect cataloged in .team/artifacts/bug-042-restart-scope-and-session.md.
|
|
1075
1121
|
if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
1076
1122
|
let positive_only: Vec<CapturedSessionCandidate> = out
|
|
1077
1123
|
.iter()
|
|
@@ -2044,6 +2090,50 @@ mod e6_session_attribution_tests {
|
|
|
2044
2090
|
f.set_modified(when).unwrap();
|
|
2045
2091
|
}
|
|
2046
2092
|
|
|
2093
|
+
/// Bug 2 (0.4.2 P0): when expected_session_id is set (restart --allow-fresh
|
|
2094
|
+
/// pre-allocates a UUID via --session-id) but the Claude CLI didn't adopt
|
|
2095
|
+
/// it (no <expected>.jsonl on disk), capture must NOT fall back to
|
|
2096
|
+
/// time-window narrowing over leader transcripts sharing the same cwd.
|
|
2097
|
+
/// Pre-fix: leader transcript with later mtime would be silently picked
|
|
2098
|
+
/// up as the worker session. Post-fix: empty list returned (caller treats
|
|
2099
|
+
/// as session-not-yet-captured and retries on next tick).
|
|
2100
|
+
#[test]
|
|
2101
|
+
fn scan_expected_session_id_miss_refuses_to_pick_leader_sibling() {
|
|
2102
|
+
let base = tmp_root("strict-no-leader-fallback");
|
|
2103
|
+
let cwd = base.join("ws");
|
|
2104
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2105
|
+
let proj = base.join("projects");
|
|
2106
|
+
// Simulate the leader's transcript and a stale earlier transcript in
|
|
2107
|
+
// the same cwd. NEITHER matches expected_session_id.
|
|
2108
|
+
let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
|
|
2109
|
+
let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
|
|
2110
|
+
let _ = (leader, stale);
|
|
2111
|
+
let ctx = CaptureSessionContext {
|
|
2112
|
+
agent_id: "claude-worker".to_string(),
|
|
2113
|
+
spawn_cwd: cwd.clone(),
|
|
2114
|
+
pane_id: None,
|
|
2115
|
+
pane_pid: None,
|
|
2116
|
+
spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
|
|
2117
|
+
// Worker was spawned with --session-id <expected> but Claude
|
|
2118
|
+
// didn't write <expected>.jsonl.
|
|
2119
|
+
expected_session_id: Some(SessionId::new(
|
|
2120
|
+
"99999999-9999-4999-8999-999999999999",
|
|
2121
|
+
)),
|
|
2122
|
+
provider_projects_root: Some(proj.clone()),
|
|
2123
|
+
};
|
|
2124
|
+
let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
2125
|
+
assert!(
|
|
2126
|
+
out.is_empty(),
|
|
2127
|
+
"expected_session_id set + no exact match → must NOT fall back to \
|
|
2128
|
+
time-window narrowing (would grab leader's transcript). \
|
|
2129
|
+
got={:?}",
|
|
2130
|
+
out.iter()
|
|
2131
|
+
.filter_map(|c| c.captured.session_id.as_ref().map(|s| s.as_str().to_string()))
|
|
2132
|
+
.collect::<Vec<_>>()
|
|
2133
|
+
);
|
|
2134
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2047
2137
|
// ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
|
|
2048
2138
|
struct HomeGuard {
|
|
2049
2139
|
prev: Option<std::ffi::OsString>,
|
|
@@ -428,11 +428,22 @@ where
|
|
|
428
428
|
.and_then(Value::as_str)
|
|
429
429
|
.filter(|value| !value.is_empty())
|
|
430
430
|
.map(str::to_string),
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
431
|
+
// 0.3.31 Codex capture correction: Codex does NOT honor
|
|
432
|
+
// `--session-id`, so any `_pending_session_id` stored for a Codex
|
|
433
|
+
// agent (stale 0.3.30 state, or the framework's pre-spawn token)
|
|
434
|
+
// is a local-only token and must NOT be used as expected_session_id
|
|
435
|
+
// — that would trigger the Stage 1 mismatch guard against Codex's
|
|
436
|
+
// real session_meta.payload.id and permanently reject the correct
|
|
437
|
+
// transcript. Codex capture anchors purely on (cwd, spawned_at).
|
|
438
|
+
expected_session_id: if matches!(provider, Provider::Codex) {
|
|
439
|
+
None
|
|
440
|
+
} else {
|
|
441
|
+
agent
|
|
442
|
+
.get("_pending_session_id")
|
|
443
|
+
.and_then(Value::as_str)
|
|
444
|
+
.filter(|value| !value.is_empty())
|
|
445
|
+
.map(SessionId::new)
|
|
446
|
+
},
|
|
436
447
|
provider_projects_root: agent
|
|
437
448
|
.get("claude_projects_root")
|
|
438
449
|
.and_then(Value::as_str)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.4.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.4.4",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.4.4",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.4.4"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|