@team-agent/installer 0.4.9 → 0.4.11
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/status_port.rs +4 -1
- package/crates/team-agent/src/compiler.rs +78 -9
- package/crates/team-agent/src/coordinator/tick.rs +96 -0
- package/crates/team-agent/src/leader/start.rs +169 -25
- package/crates/team-agent/src/lifecycle/launch.rs +155 -3
- package/crates/team-agent/src/lifecycle/profile_launch.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +279 -10
- package/crates/team-agent/src/lifecycle/restart/common.rs +17 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +22 -13
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/types.rs +86 -0
- package/crates/team-agent/src/model/enums.rs +65 -0
- package/crates/team-agent/src/model/spec.rs +34 -2
- package/crates/team-agent/src/os_probe.rs +79 -0
- package/crates/team-agent/src/provider/adapter.rs +11 -3
- package/crates/team-agent/src/provider/adapters/claude.rs +8 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +10 -0
- package/crates/team-agent/src/provider/types.rs +8 -0
- package/crates/team-agent/src/tmux_backend.rs +19 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -743,7 +743,10 @@ use rusqlite::params;
|
|
|
743
743
|
return json!({});
|
|
744
744
|
};
|
|
745
745
|
let mut out = Map::new();
|
|
746
|
-
|
|
746
|
+
// 0.4.x Phase 1: add `worker_state` (canonical 5-state product
|
|
747
|
+
// surface). `activity` is preserved alongside as the deprecated
|
|
748
|
+
// legacy classifier output (CR R3 same-source contract).
|
|
749
|
+
for key in ["status", "provider", "worker_state", "activity", "last_output_at"] {
|
|
747
750
|
if let Some(value) = input.get(key) {
|
|
748
751
|
out.insert(key.to_string(), value.clone());
|
|
749
752
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
use std::fs;
|
|
27
27
|
use std::path::Path;
|
|
28
28
|
|
|
29
|
+
use crate::model::enums::{Provider, ProviderEffort};
|
|
29
30
|
use crate::model::yaml::Value;
|
|
30
31
|
use crate::model::{paths, spec, yaml, ModelError};
|
|
31
32
|
|
|
@@ -182,17 +183,35 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
182
183
|
})
|
|
183
184
|
.collect::<Vec<_>>();
|
|
184
185
|
|
|
186
|
+
// 0.4.x provider effort MVP step 2: validate TEAM.md provider_effort early
|
|
187
|
+
// (unknown literal rejects compile). Empty/absent → no team-level effort.
|
|
188
|
+
let team_provider_effort = match string_field(&team_meta, "provider_effort") {
|
|
189
|
+
Some(raw) if !raw.trim().is_empty() => {
|
|
190
|
+
let value = raw.trim();
|
|
191
|
+
let parsed = ProviderEffort::parse(value).ok_or_else(|| {
|
|
192
|
+
ModelError::Validation(format!(
|
|
193
|
+
"{}: unknown provider_effort '{value}' (allowed: low|medium|high|xhigh|max)",
|
|
194
|
+
team_md.display()
|
|
195
|
+
))
|
|
196
|
+
})?;
|
|
197
|
+
Some(parsed)
|
|
198
|
+
}
|
|
199
|
+
_ => None,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let mut team_fields: Vec<(&str, Value)> = vec![
|
|
203
|
+
("name", Value::Str(team_name.clone())),
|
|
204
|
+
("mode", Value::Str("supervisor_worker".to_string())),
|
|
205
|
+
("objective", Value::Str(objective)),
|
|
206
|
+
("workspace", Value::Str(workspace_s)),
|
|
207
|
+
];
|
|
208
|
+
if let Some(effort) = team_provider_effort {
|
|
209
|
+
team_fields.push(("provider_effort", Value::Str(effort.as_str().to_string())));
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
let spec = map(vec![
|
|
186
213
|
("version", Value::Int(1)),
|
|
187
|
-
(
|
|
188
|
-
"team",
|
|
189
|
-
map(vec![
|
|
190
|
-
("name", Value::Str(team_name.clone())),
|
|
191
|
-
("mode", Value::Str("supervisor_worker".to_string())),
|
|
192
|
-
("objective", Value::Str(objective)),
|
|
193
|
-
("workspace", Value::Str(workspace_s)),
|
|
194
|
-
]),
|
|
195
|
-
),
|
|
214
|
+
("team", map(team_fields)),
|
|
196
215
|
(
|
|
197
216
|
"leader",
|
|
198
217
|
map(vec![
|
|
@@ -366,6 +385,56 @@ pub fn compile_role_agent(
|
|
|
366
385
|
if let Some(profile) = string_field(&meta, "profile") {
|
|
367
386
|
agent_items.push(("profile", Value::Str(profile)));
|
|
368
387
|
}
|
|
388
|
+
// 0.4.x provider effort MVP step 3: resolve effort with role > team > none.
|
|
389
|
+
// Validate (unknown literal) AND check provider/effort compatibility
|
|
390
|
+
// (max is Claude-only; emit hard error for max + non-Claude here so
|
|
391
|
+
// unsupported combinations fail at compile, not at runtime).
|
|
392
|
+
let role_effort = match string_field(&meta, "effort") {
|
|
393
|
+
Some(raw) if !raw.trim().is_empty() => {
|
|
394
|
+
let value = raw.trim();
|
|
395
|
+
let parsed = ProviderEffort::parse(value).ok_or_else(|| {
|
|
396
|
+
ModelError::Validation(format!(
|
|
397
|
+
"{}: unknown effort '{value}' (allowed: low|medium|high|xhigh|max)",
|
|
398
|
+
role_path.display()
|
|
399
|
+
))
|
|
400
|
+
})?;
|
|
401
|
+
Some(parsed)
|
|
402
|
+
}
|
|
403
|
+
_ => None,
|
|
404
|
+
};
|
|
405
|
+
let team_effort = match string_field(team_meta, "provider_effort") {
|
|
406
|
+
Some(raw) if !raw.trim().is_empty() => ProviderEffort::parse(raw.trim()),
|
|
407
|
+
_ => None,
|
|
408
|
+
};
|
|
409
|
+
let resolved_effort = role_effort.or(team_effort);
|
|
410
|
+
if let Some(effort) = resolved_effort {
|
|
411
|
+
// Reject max + non-Claude at compile time.
|
|
412
|
+
let provider_str = agent_items
|
|
413
|
+
.iter()
|
|
414
|
+
.find(|(k, _)| *k == "provider")
|
|
415
|
+
.and_then(|(_, v)| match v {
|
|
416
|
+
Value::Str(s) => Some(s.as_str()),
|
|
417
|
+
_ => None,
|
|
418
|
+
})
|
|
419
|
+
.unwrap_or("");
|
|
420
|
+
let provider_enum = match provider_str {
|
|
421
|
+
"claude" => Provider::Claude,
|
|
422
|
+
"claude_code" => Provider::ClaudeCode,
|
|
423
|
+
"codex" => Provider::Codex,
|
|
424
|
+
"copilot" => Provider::Copilot,
|
|
425
|
+
"gemini_cli" => Provider::GeminiCli,
|
|
426
|
+
"fake" => Provider::Fake,
|
|
427
|
+
_ => Provider::Codex, // unknown providers caught elsewhere
|
|
428
|
+
};
|
|
429
|
+
if effort.is_claude_only() && !matches!(provider_enum, Provider::Claude | Provider::ClaudeCode) {
|
|
430
|
+
return Err(ModelError::Validation(format!(
|
|
431
|
+
"{}: effort '{}' is only supported by claude/claude_code (provider: {provider_str})",
|
|
432
|
+
role_path.display(),
|
|
433
|
+
effort.as_str()
|
|
434
|
+
)));
|
|
435
|
+
}
|
|
436
|
+
agent_items.push(("effort", Value::Str(effort.as_str().to_string())));
|
|
437
|
+
}
|
|
369
438
|
Ok(CompiledRole {
|
|
370
439
|
id,
|
|
371
440
|
role,
|
|
@@ -3103,6 +3103,11 @@ fn write_activity(
|
|
|
3103
3103
|
.get("last_output_at")
|
|
3104
3104
|
.and_then(Value::as_str)
|
|
3105
3105
|
.map(str::to_string);
|
|
3106
|
+
// 0.4.x Phase 1: resolve the 5-state worker runtime state alongside the
|
|
3107
|
+
// legacy `activity` write. CR R3: activity field is preserved as the
|
|
3108
|
+
// deprecated surface; worker_state is the new canonical surface.
|
|
3109
|
+
let worker_state =
|
|
3110
|
+
resolve_worker_runtime_state_with_fg_pgrp(agent, Some(activity));
|
|
3106
3111
|
let Some(agent_obj) = agent.as_object_mut() else {
|
|
3107
3112
|
return previous_last_output;
|
|
3108
3113
|
};
|
|
@@ -3115,6 +3120,10 @@ fn write_activity(
|
|
|
3115
3120
|
"rationale": activity.rationale,
|
|
3116
3121
|
}),
|
|
3117
3122
|
);
|
|
3123
|
+
agent_obj.insert(
|
|
3124
|
+
"worker_state".to_string(),
|
|
3125
|
+
serde_json::json!(worker_state.as_wire()),
|
|
3126
|
+
);
|
|
3118
3127
|
if output_advanced {
|
|
3119
3128
|
let last_output_at = chrono::Utc::now().to_rfc3339();
|
|
3120
3129
|
agent_obj.insert(
|
|
@@ -3126,6 +3135,83 @@ fn write_activity(
|
|
|
3126
3135
|
previous_last_output
|
|
3127
3136
|
}
|
|
3128
3137
|
|
|
3138
|
+
/// 0.4.x Phase 1 worker-runtime-state resolver.
|
|
3139
|
+
///
|
|
3140
|
+
/// Pure resolution from (agent JSON, optional activity classifier output).
|
|
3141
|
+
/// Reads `agent.pane_id`, `agent.pane_pid`, `agent.awaiting_human_confirm`,
|
|
3142
|
+
/// and the trust/startup approval flags. Uses
|
|
3143
|
+
/// `os_probe::pane_foreground_and_root_pgrp` to detect a child process
|
|
3144
|
+
/// occupying the terminal foreground.
|
|
3145
|
+
///
|
|
3146
|
+
/// Precedence (matches Phase 1 plan §4):
|
|
3147
|
+
/// 1. Dead — pane_pid exists but `getpgid` returns no process,
|
|
3148
|
+
/// OR foreground probe explicitly reports the PID gone.
|
|
3149
|
+
/// 2. Blocked — agent.awaiting_human_confirm / startup-trust flags.
|
|
3150
|
+
/// 3. Busy — fg_pgrp != root_pgrp (child occupies terminal) OR
|
|
3151
|
+
/// JSONL classifier reports Working.
|
|
3152
|
+
/// 4. ProbablyIdle — JSONL classifier reports Idle AND no fg-pgrp
|
|
3153
|
+
/// conflict (or probe unavailable + activity idle).
|
|
3154
|
+
/// 5. Unknown — missing pane_pid, probe error, or no decisive
|
|
3155
|
+
/// signal. Iron law: never silently Idle.
|
|
3156
|
+
pub(crate) fn resolve_worker_runtime_state_with_fg_pgrp(
|
|
3157
|
+
agent: &Value,
|
|
3158
|
+
activity: Option<&crate::messaging::AgentActivity>,
|
|
3159
|
+
) -> crate::messaging::WorkerRuntimeState {
|
|
3160
|
+
use crate::messaging::{ActivityStatus, WorkerRuntimeState};
|
|
3161
|
+
|
|
3162
|
+
// §4.2 Blocked: trust prompt / awaiting human confirm.
|
|
3163
|
+
let blocked = agent
|
|
3164
|
+
.get("awaiting_human_confirm")
|
|
3165
|
+
.and_then(Value::as_bool)
|
|
3166
|
+
.unwrap_or(false)
|
|
3167
|
+
|| agent
|
|
3168
|
+
.get("approval")
|
|
3169
|
+
.and_then(Value::as_str)
|
|
3170
|
+
.is_some_and(|s| s == "pending" || s == "awaiting_trust_prompt")
|
|
3171
|
+
|| agent
|
|
3172
|
+
.get("approval_status")
|
|
3173
|
+
.and_then(Value::as_str)
|
|
3174
|
+
.is_some_and(|s| s == "pending" || s == "awaiting_trust_prompt");
|
|
3175
|
+
if blocked {
|
|
3176
|
+
return WorkerRuntimeState::Blocked;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// §4.3 Busy via fg-pgrp probe (when pane_pid is available).
|
|
3180
|
+
let pane_pid = agent.get("pane_pid").and_then(Value::as_u64).map(|p| p as u32);
|
|
3181
|
+
if let Some(pid) = pane_pid {
|
|
3182
|
+
match crate::os_probe::pane_foreground_and_root_pgrp(pid) {
|
|
3183
|
+
Ok(Some((tpgid, pgid))) => {
|
|
3184
|
+
if tpgid != pgid {
|
|
3185
|
+
return WorkerRuntimeState::Busy;
|
|
3186
|
+
}
|
|
3187
|
+
// Same pgrp — pane process owns foreground. Defer to
|
|
3188
|
+
// JSONL/activity for finer state.
|
|
3189
|
+
}
|
|
3190
|
+
Ok(None) => {
|
|
3191
|
+
// Probe could not read: missing PID, no controlling
|
|
3192
|
+
// terminal, ps unavailable. Fall through to activity
|
|
3193
|
+
// classifier; if that's also missing/uncertain, the
|
|
3194
|
+
// function returns Unknown.
|
|
3195
|
+
}
|
|
3196
|
+
Err(_) => {
|
|
3197
|
+
// Subprocess error — degrade to Unknown unless activity
|
|
3198
|
+
// is decisive.
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// §4.4/4.5 Activity-derived classification.
|
|
3204
|
+
if let Some(activity) = activity {
|
|
3205
|
+
return match activity.status {
|
|
3206
|
+
ActivityStatus::Working => WorkerRuntimeState::Busy,
|
|
3207
|
+
ActivityStatus::Idle => WorkerRuntimeState::ProbablyIdle,
|
|
3208
|
+
ActivityStatus::Stuck | ActivityStatus::Uncertain => WorkerRuntimeState::Unknown,
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
WorkerRuntimeState::Unknown
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3129
3215
|
fn activity_status_wire(status: crate::messaging::ActivityStatus) -> &'static str {
|
|
3130
3216
|
match status {
|
|
3131
3217
|
crate::messaging::ActivityStatus::Idle => "idle",
|
|
@@ -3144,6 +3230,16 @@ fn agent_health_status_wire(status: crate::messaging::ActivityStatus) -> &'stati
|
|
|
3144
3230
|
}
|
|
3145
3231
|
}
|
|
3146
3232
|
|
|
3233
|
+
/// 0.4.x Phase 1: read the worker_state wire string that `write_activity`
|
|
3234
|
+
/// persisted alongside the legacy activity. Returns None when no worker_state
|
|
3235
|
+
/// has been written yet (older state row pre-upgrade).
|
|
3236
|
+
pub(crate) fn agent_worker_state(agent: &Value) -> Option<crate::messaging::WorkerRuntimeState> {
|
|
3237
|
+
agent
|
|
3238
|
+
.get("worker_state")
|
|
3239
|
+
.and_then(Value::as_str)
|
|
3240
|
+
.map(crate::messaging::WorkerRuntimeState::parse_wire)
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3147
3243
|
fn write_agent_health(
|
|
3148
3244
|
store: &crate::message_store::MessageStore,
|
|
3149
3245
|
team: &str,
|
|
@@ -61,12 +61,20 @@ pub fn leader_start_plan(
|
|
|
61
61
|
// `managed_team_session_name(identity) = team-<team_id>` which is the
|
|
62
62
|
// worker session — that co-location is the structural root of
|
|
63
63
|
// E49/E51/E53/E57-3/E60.
|
|
64
|
+
// 0.4.10+ mirror-session fix v2 (option B): managed-mode session name
|
|
65
|
+
// gets a per-launch nonce so each `team-agent <provider>` entry in the
|
|
66
|
+
// same workspace creates its OWN tmux session — matching the user
|
|
67
|
+
// expectation that `tmux new-session + claude` is independent every
|
|
68
|
+
// time. Pre-fix the managed path used the workspace-keyed name (same
|
|
69
|
+
// session for every entry) and silently attached the second client →
|
|
70
|
+
// UI mirror. The external/attach paths keep the stable workspace-keyed
|
|
71
|
+
// name (per-launch nonce would break `--attach-session` reattach).
|
|
64
72
|
let session_name = if external_path {
|
|
65
73
|
attach_session
|
|
66
74
|
.cloned()
|
|
67
75
|
.or_else(|| Some(leader_session_name(provider, workspace)))
|
|
68
76
|
} else {
|
|
69
|
-
Some(
|
|
77
|
+
Some(managed_leader_session_name(provider, workspace))
|
|
70
78
|
};
|
|
71
79
|
let in_tmux = std::env::var_os("TMUX").is_some();
|
|
72
80
|
if !in_tmux {
|
|
@@ -266,6 +274,42 @@ pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName
|
|
|
266
274
|
))
|
|
267
275
|
}
|
|
268
276
|
|
|
277
|
+
/// 0.4.10+ mirror-session fix v2: managed-mode session name with a
|
|
278
|
+
/// per-launch nonce.
|
|
279
|
+
///
|
|
280
|
+
/// Format: `team-agent-leader-<provider>-<folder>-<hash>-<nonce>`.
|
|
281
|
+
/// `nonce` = `<pid_hex>-<epoch_nanos_hex>`. The pid distinguishes
|
|
282
|
+
/// concurrent launches; the epoch_nanos distinguishes sequential ones.
|
|
283
|
+
///
|
|
284
|
+
/// Each `team-agent <provider>` entry in the managed (non-tmux) path gets
|
|
285
|
+
/// its OWN session, matching the user expectation that `tmux new-session
|
|
286
|
+
/// + claude` is independent every time. The workspace-keyed prefix is
|
|
287
|
+
/// preserved so existing leader-session-anchored protections
|
|
288
|
+
/// (`LEADER_SESSION_PREFIX` matchers in shutdown/cli/mod.rs) still
|
|
289
|
+
/// classify these sessions as leader sessions.
|
|
290
|
+
///
|
|
291
|
+
/// External / attach paths keep the stable `leader_session_name` —
|
|
292
|
+
/// per-launch nonce would break `--attach-session <name>` reattach
|
|
293
|
+
/// semantics.
|
|
294
|
+
pub fn managed_leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
|
|
295
|
+
let resolved = resolve_workspace_for_hash(workspace);
|
|
296
|
+
let folder_raw = resolved
|
|
297
|
+
.file_name()
|
|
298
|
+
.and_then(|n| n.to_str())
|
|
299
|
+
.unwrap_or("workspace");
|
|
300
|
+
let folder = sanitize_session_folder(folder_raw);
|
|
301
|
+
let hash = sha1_hex_prefix(resolved.to_string_lossy().as_bytes(), 8);
|
|
302
|
+
let pid = std::process::id();
|
|
303
|
+
let epoch_nanos = std::time::SystemTime::now()
|
|
304
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
305
|
+
.map(|d| d.as_nanos())
|
|
306
|
+
.unwrap_or(0);
|
|
307
|
+
SessionName::new(format!(
|
|
308
|
+
"{LEADER_SESSION_PREFIX}{}-{folder}-{hash}-{pid:x}-{epoch_nanos:x}",
|
|
309
|
+
provider_wire(provider)
|
|
310
|
+
))
|
|
311
|
+
}
|
|
312
|
+
|
|
269
313
|
fn start_argv(
|
|
270
314
|
mode: LeaderStartMode,
|
|
271
315
|
provider: Provider,
|
|
@@ -423,31 +467,14 @@ fn ensure_managed_leader_pane(
|
|
|
423
467
|
plan: &LeaderStartPlan,
|
|
424
468
|
workspace: &Path,
|
|
425
469
|
) -> Result<SpawnResult, LeaderError> {
|
|
470
|
+
// 0.4.10+ mirror-session fix v2 (option B): each managed launch gets
|
|
471
|
+
// its OWN session name (via `managed_leader_session_name`'s nonce).
|
|
472
|
+
// The historical "session already exists → reuse pane" branch is
|
|
473
|
+
// structurally unreachable now (the per-launch session never
|
|
474
|
+
// preexists). If a caller bypasses the nonce path and reuses a name,
|
|
475
|
+
// tmux's own duplicate-session error will surface — better than
|
|
476
|
+
// silently attaching a second client.
|
|
426
477
|
if transport.has_session(session).unwrap_or(false) {
|
|
427
|
-
if transport
|
|
428
|
-
.list_windows(session)
|
|
429
|
-
.unwrap_or_default()
|
|
430
|
-
.iter()
|
|
431
|
-
.any(|existing| existing.as_str() == window.as_str())
|
|
432
|
-
{
|
|
433
|
-
if let Some(existing) = transport
|
|
434
|
-
.list_targets()
|
|
435
|
-
.unwrap_or_default()
|
|
436
|
-
.into_iter()
|
|
437
|
-
.find(|pane| {
|
|
438
|
-
pane.session.as_str() == session.as_str()
|
|
439
|
-
&& pane.window_name.as_ref().map(WindowName::as_str)
|
|
440
|
-
== Some(window.as_str())
|
|
441
|
-
})
|
|
442
|
-
{
|
|
443
|
-
return Ok(SpawnResult {
|
|
444
|
-
pane_id: existing.pane_id,
|
|
445
|
-
session: session.clone(),
|
|
446
|
-
window: window.clone(),
|
|
447
|
-
child_pid: existing.pane_pid,
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
478
|
// 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
|
|
452
479
|
// provider_env_unsets (single source of truth) + spawn through the
|
|
453
480
|
// leader shell wrapper so provider exit returns to a shell, not
|
|
@@ -1586,4 +1613,121 @@ mod tests {
|
|
|
1586
1613
|
assert_eq!(sent[0].0, Target::Pane(PaneId::new("%0")));
|
|
1587
1614
|
assert_eq!(sent[0].1, vec![Key::Enter]);
|
|
1588
1615
|
}
|
|
1616
|
+
|
|
1617
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1618
|
+
// 0.4.10+ mirror-session fix v2 (option B: independent session per launch).
|
|
1619
|
+
//
|
|
1620
|
+
// managed_leader_session_name appends a per-launch nonce so each
|
|
1621
|
+
// `team-agent <provider>` entry in the same workspace creates its own
|
|
1622
|
+
// tmux session, matching `tmux new-session + claude` semantics.
|
|
1623
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1624
|
+
|
|
1625
|
+
#[test]
|
|
1626
|
+
fn managed_leader_session_name_carries_leader_prefix() {
|
|
1627
|
+
// Protection invariant: every session name returned by the
|
|
1628
|
+
// managed path must still match LEADER_SESSION_PREFIX so the
|
|
1629
|
+
// shutdown / cli/mod.rs prefix matchers still classify it as a
|
|
1630
|
+
// leader session.
|
|
1631
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
1632
|
+
"ta_rs_mgr_prefix_{}",
|
|
1633
|
+
std::process::id()
|
|
1634
|
+
));
|
|
1635
|
+
let _ = std::fs::create_dir_all(&workspace);
|
|
1636
|
+
let name = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
|
|
1637
|
+
assert!(
|
|
1638
|
+
name.as_str().starts_with(super::super::LEADER_SESSION_PREFIX),
|
|
1639
|
+
"managed session name must carry LEADER_SESSION_PREFIX (`{}`); \
|
|
1640
|
+
got `{}`",
|
|
1641
|
+
super::super::LEADER_SESSION_PREFIX,
|
|
1642
|
+
name.as_str()
|
|
1643
|
+
);
|
|
1644
|
+
assert!(
|
|
1645
|
+
name.as_str().contains("claude_code"),
|
|
1646
|
+
"managed session name must include provider wire; got `{}`",
|
|
1647
|
+
name.as_str()
|
|
1648
|
+
);
|
|
1649
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
#[test]
|
|
1653
|
+
fn managed_leader_session_name_is_unique_per_call() {
|
|
1654
|
+
// Two consecutive calls must return DIFFERENT names — the
|
|
1655
|
+
// per-launch nonce defeats the mirror-session bug.
|
|
1656
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
1657
|
+
"ta_rs_mgr_unique_{}",
|
|
1658
|
+
std::process::id()
|
|
1659
|
+
));
|
|
1660
|
+
let _ = std::fs::create_dir_all(&workspace);
|
|
1661
|
+
let a = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
|
|
1662
|
+
// Sleep > 1ns to ensure epoch_nanos advances even on coarse clocks.
|
|
1663
|
+
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
1664
|
+
let b = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
|
|
1665
|
+
assert_ne!(
|
|
1666
|
+
a.as_str(),
|
|
1667
|
+
b.as_str(),
|
|
1668
|
+
"two managed launches in the same workspace must get \
|
|
1669
|
+
DIFFERENT session names (mirror-session fix v2); got \
|
|
1670
|
+
`{}` vs `{}`",
|
|
1671
|
+
a.as_str(),
|
|
1672
|
+
b.as_str()
|
|
1673
|
+
);
|
|
1674
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
#[test]
|
|
1678
|
+
fn leader_session_name_is_stable_across_calls() {
|
|
1679
|
+
// External / attach paths must keep the stable workspace-keyed
|
|
1680
|
+
// name — `--attach-session <name>` reattach would break if the
|
|
1681
|
+
// name varied per call.
|
|
1682
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
1683
|
+
"ta_rs_lsn_stable_{}",
|
|
1684
|
+
std::process::id()
|
|
1685
|
+
));
|
|
1686
|
+
let _ = std::fs::create_dir_all(&workspace);
|
|
1687
|
+
let a = super::leader_session_name(Provider::ClaudeCode, &workspace);
|
|
1688
|
+
let b = super::leader_session_name(Provider::ClaudeCode, &workspace);
|
|
1689
|
+
assert_eq!(
|
|
1690
|
+
a.as_str(),
|
|
1691
|
+
b.as_str(),
|
|
1692
|
+
"leader_session_name must be deterministic per workspace \
|
|
1693
|
+
(external/attach paths depend on stability); got `{}` vs `{}`",
|
|
1694
|
+
a.as_str(),
|
|
1695
|
+
b.as_str()
|
|
1696
|
+
);
|
|
1697
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/// CR C-5 source grep guard: managed mode passes the nonce session
|
|
1701
|
+
/// name through `leader_start_plan` so the per-launch independence
|
|
1702
|
+
/// reaches `ensure_managed_leader_pane`. External/attach paths must
|
|
1703
|
+
/// keep `leader_session_name`.
|
|
1704
|
+
#[test]
|
|
1705
|
+
fn managed_path_uses_nonce_session_name_grep_guard() {
|
|
1706
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
1707
|
+
let start_rs = manifest.join("src").join("leader").join("start.rs");
|
|
1708
|
+
let contents =
|
|
1709
|
+
std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
|
|
1710
|
+
// leader_start_plan must dispatch to managed_leader_session_name
|
|
1711
|
+
// on the managed path.
|
|
1712
|
+
let start = contents
|
|
1713
|
+
.find("pub fn leader_start_plan(")
|
|
1714
|
+
.expect("leader_start_plan must exist");
|
|
1715
|
+
let end = contents[start + 1..]
|
|
1716
|
+
.find("\npub fn ")
|
|
1717
|
+
.or_else(|| contents[start + 1..].find("\nfn "))
|
|
1718
|
+
.map(|p| start + 1 + p)
|
|
1719
|
+
.unwrap_or(contents.len());
|
|
1720
|
+
let body = &contents[start..end];
|
|
1721
|
+
assert!(
|
|
1722
|
+
body.contains("managed_leader_session_name(provider, workspace)"),
|
|
1723
|
+
"leader_start_plan must call managed_leader_session_name on \
|
|
1724
|
+
the managed path (mirror-session fix v2); body excerpt: {body}"
|
|
1725
|
+
);
|
|
1726
|
+
// The external_path branch must still use the stable name.
|
|
1727
|
+
assert!(
|
|
1728
|
+
body.contains("leader_session_name(provider, workspace)"),
|
|
1729
|
+
"leader_start_plan must keep leader_session_name on the \
|
|
1730
|
+
external/attach paths; body excerpt: {body}"
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1589
1733
|
}
|