@team-agent/installer 0.5.112 → 0.5.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +100 -44
- package/crates/team-agent/src/cli/emit.rs +127 -98
- package/crates/team-agent/src/cli/leaders.rs +32 -32
- package/crates/team-agent/src/cli/mod.rs +6 -6
- package/crates/team-agent/src/cli/named_address.rs +17 -16
- package/crates/team-agent/src/cli/route.rs +4 -4
- package/crates/team-agent/src/cli/send/presentation.rs +19 -18
- package/crates/team-agent/src/cli/send/resolve.rs +11 -11
- package/crates/team-agent/src/cli/send.rs +4 -4
- package/crates/team-agent/src/cli/spec.rs +29 -29
- package/crates/team-agent/src/cli/status.rs +7 -7
- package/crates/team-agent/src/cli/tests/base.rs +6 -6
- package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +7 -7
- package/crates/team-agent/src/cli/triage.rs +53 -2
- package/crates/team-agent/src/cli/types.rs +8 -8
- package/crates/team-agent/src/compiler/tests.rs +12 -0
- package/crates/team-agent/src/compiler.rs +0 -9
- package/crates/team-agent/src/leader/start.rs +5 -0
- package/crates/team-agent/src/leader/tests/identity.rs +21 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +325 -14
- package/crates/team-agent/src/lifecycle/launch.rs +2 -2
- package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
- package/crates/team-agent/src/provider/adapter.rs +8 -59
- package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
- package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
- package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
- package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
- package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
- package/crates/team-agent/src/provider/command_helpers.rs +45 -0
- package/crates/team-agent/src/provider/mod.rs +1 -0
- package/crates/team-agent/src/provider/model_catalog.rs +21 -28
- package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
- package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
- package/package.json +4 -4
|
@@ -209,7 +209,7 @@ fn csv_agent_status(
|
|
|
209
209
|
health: Option<&serde_json::Map<String, Value>>,
|
|
210
210
|
) -> &'static str {
|
|
211
211
|
if stale_agent_bucket(agent).is_some() {
|
|
212
|
-
return "
|
|
212
|
+
return "error";
|
|
213
213
|
}
|
|
214
214
|
let raw = agent
|
|
215
215
|
.get("status")
|
|
@@ -226,7 +226,7 @@ fn csv_agent_status(
|
|
|
226
226
|
|| matches!(hstatus.as_str(), "failed" | "error" | "spawn_failed")
|
|
227
227
|
|| matches!(raw.as_str(), "stopped" | "done")
|
|
228
228
|
{
|
|
229
|
-
return "
|
|
229
|
+
return "error";
|
|
230
230
|
}
|
|
231
231
|
let pane_present = agent
|
|
232
232
|
.get("pane_id")
|
|
@@ -239,21 +239,21 @@ fn csv_agent_status(
|
|
|
239
239
|
.filter(|pane| !pane.is_empty())
|
|
240
240
|
.is_some();
|
|
241
241
|
if matches!(raw.as_str(), "running" | "busy" | "working") && !pane_present {
|
|
242
|
-
return "
|
|
242
|
+
return "error";
|
|
243
243
|
}
|
|
244
244
|
// 0.5.35 R4: canonical UNKNOWN beats legacy WORKING in human CSV too.
|
|
245
245
|
if canonical_worker_state_is_unknown(agent) {
|
|
246
|
-
return "
|
|
246
|
+
return "unknown";
|
|
247
247
|
}
|
|
248
248
|
if raw == "idle" || hstatus == "idle" {
|
|
249
|
-
return "
|
|
249
|
+
return "idle";
|
|
250
250
|
}
|
|
251
251
|
if matches!(raw.as_str(), "running" | "busy" | "working")
|
|
252
252
|
|| matches!(hstatus.as_str(), "running" | "working" | "busy")
|
|
253
253
|
{
|
|
254
|
-
return "
|
|
254
|
+
return "working";
|
|
255
255
|
}
|
|
256
|
-
"
|
|
256
|
+
"error"
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/// `_latest_result_line`(`commands.py:333-337`):agent_id/summary/created_at 渲染单行。
|
|
@@ -420,7 +420,7 @@ fn format_status_csv_preserves_agent_order_and_collapses_errors() {
|
|
|
420
420
|
});
|
|
421
421
|
assert_eq!(
|
|
422
422
|
format_status_csv(&data),
|
|
423
|
-
"zeta
|
|
423
|
+
"zeta,idle\nalpha,working\nerr_failed,error\nerr_missing_pane,error\nerr_unknown,error\nerr_stopped,error"
|
|
424
424
|
);
|
|
425
425
|
}
|
|
426
426
|
|
|
@@ -489,7 +489,7 @@ fn cli_error_payload_plain_runtime() {
|
|
|
489
489
|
assert_eq!(payload.error, "some other error");
|
|
490
490
|
assert_eq!(
|
|
491
491
|
payload.action,
|
|
492
|
-
"
|
|
492
|
+
"Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
|
|
493
493
|
);
|
|
494
494
|
assert_eq!(payload.log, "/tmp/y.log");
|
|
495
495
|
assert_eq!(payload.reason, None);
|
|
@@ -511,7 +511,7 @@ fn cli_error_payload_tmux_conflict_quick_start_enrichment() {
|
|
|
511
511
|
// context reset is only through restart --allow-fresh with explicit consent.
|
|
512
512
|
assert_eq!(
|
|
513
513
|
payload.action,
|
|
514
|
-
"
|
|
514
|
+
"Terminal session `my-team` already exists and may belong to your existing team. Resume with team-agent restart; use --allow-fresh only after explicitly accepting loss of saved context. To create another team, change the name in TEAM.md before quick-start. Do not shut down an existing team to bypass this refusal."
|
|
515
515
|
);
|
|
516
516
|
let next = payload.next_actions.as_ref().unwrap();
|
|
517
517
|
assert_eq!(next.len(), 2);
|
|
@@ -529,11 +529,11 @@ fn cli_error_payload_tmux_conflict_non_quick_start_enrichment() {
|
|
|
529
529
|
assert_eq!(payload.session_name.as_deref(), Some("my-team"));
|
|
530
530
|
assert_eq!(
|
|
531
531
|
payload.action,
|
|
532
|
-
"
|
|
532
|
+
"Terminal session `my-team` already exists and may belong to a running team. Do not shut it down; choose another team name before starting."
|
|
533
533
|
);
|
|
534
534
|
let next = payload.next_actions.as_ref().unwrap();
|
|
535
535
|
assert_eq!(next.len(), 1);
|
|
536
|
-
assert!(next[0].contains("
|
|
536
|
+
assert!(next[0].contains("team") && next[0].contains("name"));
|
|
537
537
|
}
|
|
538
538
|
|
|
539
539
|
#[test]
|
|
@@ -749,7 +749,7 @@ fn cmd_leader_passthrough_help_is_pure_human_guidance() {
|
|
|
749
749
|
let result = cmd_leader_passthrough(provider, &[alias.into()], &cwd).unwrap();
|
|
750
750
|
assert_eq!(result.exit, ExitCode::Ok);
|
|
751
751
|
assert!(
|
|
752
|
-
matches!(&result.output, CmdOutput::Human(text) if text.contains(provider) && text.contains("
|
|
752
|
+
matches!(&result.output, CmdOutput::Human(text) if text.contains(provider) && text.contains("Usage") && text.contains("Examples") && text.contains("Next Action"))
|
|
753
753
|
);
|
|
754
754
|
}
|
|
755
755
|
}
|
|
@@ -457,7 +457,7 @@ fn red_send_target_none_is_not_broadcast() {
|
|
|
457
457
|
assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
//
|
|
460
|
+
// Doctor retains bounded triage navigation plus complete diagnostic details.
|
|
461
461
|
#[test]
|
|
462
462
|
fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
|
|
463
463
|
let mut args = DoctorArgs {
|
|
@@ -483,8 +483,9 @@ fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
|
|
|
483
483
|
};
|
|
484
484
|
assert_eq!(text, crate::cli::triage::render("doctor", &report));
|
|
485
485
|
assert!(text.starts_with("doctor:"));
|
|
486
|
-
assert_eq!(text.lines().count(),
|
|
486
|
+
assert_eq!(text.lines().count(), 2 + report["issues"].as_array().unwrap().len()
|
|
487
487
|
+ report["suggested_repairs"].as_array().unwrap().len());
|
|
488
|
+
assert!(text.lines().last().unwrap().starts_with("details: "));
|
|
488
489
|
}
|
|
489
490
|
|
|
490
491
|
// ---- #13 / #27 (P2): run() must NOT treat 'claude_code' as a passthrough trigger ----
|
|
@@ -257,7 +257,7 @@ fn inbox_excludes_leader_stage_result_notifications() {
|
|
|
257
257
|
// the deterministic check sub-shapes (run_id is a random uuid; not value-locked). ────────────────
|
|
258
258
|
#[test]
|
|
259
259
|
fn comms_selftest_golden_boundary_scope_and_check_shapes() {
|
|
260
|
-
let boundary = "
|
|
260
|
+
let boundary = "This checks terminal connection consistency only; it does not send a message or prove that a worker replied.";
|
|
261
261
|
let ws = tmp_workspace();
|
|
262
262
|
let v = diagnose_port::comms_selftest(&ws, None, None).expect("comms_selftest");
|
|
263
263
|
let obj = v.as_object().expect("comms dict");
|
|
@@ -377,21 +377,21 @@ fn send_to_name_mutual_exclusion() {
|
|
|
377
377
|
let args = named_send_args(&cwd, Some("team-a/qa"), Some("%1"), None, &["hello"]);
|
|
378
378
|
let err = cmd_send(&args).unwrap_err();
|
|
379
379
|
assert!(
|
|
380
|
-
matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("
|
|
380
|
+
matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("cannot be combined")),
|
|
381
381
|
"expected --to-name mutual exclusion, got {err:?}"
|
|
382
382
|
);
|
|
383
383
|
|
|
384
384
|
let args = named_send_args(&cwd, Some("team-a/qa"), None, Some("worker"), &["hello"]);
|
|
385
385
|
let err = cmd_send(&args).unwrap_err();
|
|
386
386
|
assert!(
|
|
387
|
-
matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("
|
|
387
|
+
matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("cannot be combined")),
|
|
388
388
|
"expected --to-name/--to mutual exclusion, got {err:?}"
|
|
389
389
|
);
|
|
390
390
|
|
|
391
391
|
let args = named_send_args(&cwd, Some("team-a/qa"), None, None, &[]);
|
|
392
392
|
let err = cmd_send(&args).unwrap_err();
|
|
393
393
|
assert!(
|
|
394
|
-
matches!(err, CliError::Usage(ref message) if message == "
|
|
394
|
+
matches!(err, CliError::Usage(ref message) if message == "Provide a task message; --to-name cannot send an empty message"),
|
|
395
395
|
"expected empty-message usage error, got {err:?}"
|
|
396
396
|
);
|
|
397
397
|
let _ = std::fs::remove_dir_all(&cwd);
|
|
@@ -641,11 +641,11 @@ fn cmd_send_joins_message_with_single_space() {
|
|
|
641
641
|
// 应立即失败以强制重新审视,而不是悄悄跑一条从未验过的分支。
|
|
642
642
|
assert!(
|
|
643
643
|
!delivered,
|
|
644
|
-
"hermetic seed
|
|
644
|
+
"hermetic seed must not produce delivered=true; the fixture assumption changed"
|
|
645
645
|
);
|
|
646
646
|
assert_eq!(
|
|
647
647
|
v.get("reminder").and_then(|reminder| reminder.as_str()),
|
|
648
|
-
Some("
|
|
648
|
+
Some("Task accepted, but delivery is unconfirmed. Check team-agent status/doctor; wait for an actual reply without repeated sends.")
|
|
649
649
|
);
|
|
650
650
|
}
|
|
651
651
|
}
|
|
@@ -665,15 +665,15 @@ fn cmd_send_default_human_output_is_brief_without_false_delivered() {
|
|
|
665
665
|
"send must separate outcome and next action: {text}"
|
|
666
666
|
);
|
|
667
667
|
assert!(
|
|
668
|
-
lines[0].contains("
|
|
668
|
+
lines[0].contains("Task accepted") && lines[0].contains("delivery to the agent's conversation is still pending"),
|
|
669
669
|
"queued send must report its true pending outcome: {text}"
|
|
670
670
|
);
|
|
671
671
|
assert!(
|
|
672
|
-
lines[1].contains("
|
|
672
|
+
lines[1].contains("Next:")
|
|
673
673
|
&& lines[1].contains("team-agent status")
|
|
674
674
|
&& lines[1].contains("doctor")
|
|
675
|
-
&& lines[1].contains("
|
|
676
|
-
&& lines[1].contains("
|
|
675
|
+
&& lines[1].contains("wait for an actual reply")
|
|
676
|
+
&& lines[1].contains("without repeated sends"),
|
|
677
677
|
"queued send must give honest next steps: {text}"
|
|
678
678
|
);
|
|
679
679
|
assert!(
|
|
@@ -951,7 +951,7 @@ fn cmd_send_unknown_task_surfaces_golden_error_envelope_not_silent() {
|
|
|
951
951
|
);
|
|
952
952
|
assert_eq!(
|
|
953
953
|
payload.action,
|
|
954
|
-
"
|
|
954
|
+
"Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
|
|
955
955
|
);
|
|
956
956
|
let _ = std::fs::remove_dir_all(&ws);
|
|
957
957
|
}
|
|
@@ -56,9 +56,34 @@ pub(crate) fn render(command: &str, report: &Value) -> String {
|
|
|
56
56
|
for repair in repairs {
|
|
57
57
|
lines.push(bounded(format!("repair: {}", summarize(repair, true))));
|
|
58
58
|
}
|
|
59
|
+
// Short triage lines are navigation, not the authoritative refusal. Retain
|
|
60
|
+
// complete causes/actions as JSON, without dumping unrelated runtime or
|
|
61
|
+
// provider diagnostics. Values have already crossed the redaction boundary.
|
|
62
|
+
let details = diagnostic_details(&report);
|
|
63
|
+
if details.as_object().is_some_and(|details| !details.is_empty()) {
|
|
64
|
+
lines.push(format!("details: {details}"));
|
|
65
|
+
}
|
|
59
66
|
lines.join("\n")
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
// Shared by doctor/diagnose and send. Never include message bodies, profiles or
|
|
70
|
+
// unrelated runtime/provider dumps in a command's diagnostic output.
|
|
71
|
+
pub(crate) fn diagnostic_details(report: &Value) -> Value {
|
|
72
|
+
let report = crate::redaction::redact_external_value(report);
|
|
73
|
+
let mut details = serde_json::Map::new();
|
|
74
|
+
for key in [
|
|
75
|
+
"error", "status", "summary", "reason", "blockers", "action",
|
|
76
|
+
"next_action", "next_actions", "issues", "suggested_repairs",
|
|
77
|
+
"state_path", "log", "schema_error", "stage", "delivery_status",
|
|
78
|
+
"message_status", "verification", "channel", "turn_verification",
|
|
79
|
+
] {
|
|
80
|
+
if let Some(value) = report.get(key) {
|
|
81
|
+
details.insert(key.to_string(), value.clone());
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
Value::Object(details)
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
fn array_items(value: Option<&Value>) -> &[Value] {
|
|
63
88
|
value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
|
|
64
89
|
}
|
|
@@ -126,6 +151,7 @@ fn bounded(line: String) -> String {
|
|
|
126
151
|
|
|
127
152
|
#[cfg(test)]
|
|
128
153
|
mod tests {
|
|
154
|
+
#![allow(clippy::unwrap_used)]
|
|
129
155
|
use super::*;
|
|
130
156
|
use serde_json::json;
|
|
131
157
|
|
|
@@ -149,12 +175,37 @@ mod tests {
|
|
|
149
175
|
#[test]
|
|
150
176
|
fn renderer_sanitizes_and_bounds_utf8_lines() {
|
|
151
177
|
let report = json!({"ok": false, "issues": ["问题\n".to_string() + &"x".repeat(200)]});
|
|
152
|
-
|
|
153
|
-
|
|
178
|
+
let output = render("doctor", &report);
|
|
179
|
+
for line in output.lines() {
|
|
180
|
+
if let Some(details) = line.strip_prefix("details: ") {
|
|
181
|
+
let parsed: Value = serde_json::from_str(details).unwrap();
|
|
182
|
+
assert_eq!(parsed.get("issues"), report.get("issues"));
|
|
183
|
+
} else {
|
|
184
|
+
assert!(line.len() <= LINE_LIMIT_BYTES);
|
|
185
|
+
}
|
|
154
186
|
assert!(!line.chars().any(char::is_control));
|
|
155
187
|
}
|
|
156
188
|
}
|
|
157
189
|
|
|
190
|
+
#[test]
|
|
191
|
+
fn renderer_preserves_typed_failure_details_and_long_actions() {
|
|
192
|
+
let action = "inspect the precise failure ".repeat(20);
|
|
193
|
+
let report = json!({
|
|
194
|
+
"ok": false,
|
|
195
|
+
"status": "preflight_blocked",
|
|
196
|
+
"reason": "exact refusal",
|
|
197
|
+
"blockers": [{"stage": "database", "reason": "permission denied"}],
|
|
198
|
+
"next_actions": [action],
|
|
199
|
+
"state_path": "/tmp/workspace/.team/runtime/state.json"
|
|
200
|
+
});
|
|
201
|
+
let output = render("doctor", &report);
|
|
202
|
+
let details = output.lines().find_map(|line| line.strip_prefix("details: ")).unwrap();
|
|
203
|
+
let parsed: Value = serde_json::from_str(details).unwrap();
|
|
204
|
+
for key in ["status", "reason", "blockers", "next_actions", "state_path"] {
|
|
205
|
+
assert_eq!(parsed.get(key), report.get(key), "lost {key}");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
#[test]
|
|
159
210
|
fn renderer_projects_secret_findings_without_values() {
|
|
160
211
|
let report = json!({
|
|
@@ -84,7 +84,7 @@ impl CliError {
|
|
|
84
84
|
ok: false,
|
|
85
85
|
error: error.clone(),
|
|
86
86
|
action: if super::spec::command_spec(command).is_some() {
|
|
87
|
-
"
|
|
87
|
+
"Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
|
|
88
88
|
} else {
|
|
89
89
|
"run `team-agent doctor` or inspect the log path shown here"
|
|
90
90
|
}
|
|
@@ -99,27 +99,27 @@ impl CliError {
|
|
|
99
99
|
payload.session_name = Some(session.clone());
|
|
100
100
|
if command == "quick-start" {
|
|
101
101
|
payload.action = format!(
|
|
102
|
-
"
|
|
102
|
+
"Terminal session `{session}` already exists and may belong to your existing team. Resume with team-agent restart; use --allow-fresh only after explicitly accepting loss of saved context. To create another team, change the name in TEAM.md before quick-start. Do not shut down an existing team to bypass this refusal."
|
|
103
103
|
);
|
|
104
104
|
payload.next_actions = Some(vec![
|
|
105
|
-
"
|
|
106
|
-
"
|
|
105
|
+
"If this is your existing team, resume it with team-agent restart.".to_string(),
|
|
106
|
+
"To create another team, change the name in TEAM.md before running team-agent quick-start."
|
|
107
107
|
.to_string(),
|
|
108
108
|
]);
|
|
109
109
|
} else {
|
|
110
110
|
payload.action = format!(
|
|
111
|
-
"
|
|
111
|
+
"Terminal session `{session}` already exists and may belong to a running team. Do not shut it down; choose another team name before starting."
|
|
112
112
|
);
|
|
113
|
-
payload.next_actions = Some(vec!["
|
|
113
|
+
payload.next_actions = Some(vec!["Choose another team name before starting.".to_string()]);
|
|
114
114
|
}
|
|
115
115
|
} else if error.contains("Team Agent launcher flag")
|
|
116
116
|
&& error.contains("must appear before --")
|
|
117
117
|
{
|
|
118
118
|
payload.action =
|
|
119
|
-
String::from("
|
|
119
|
+
String::from("Place Team Agent launcher options before --; only native provider arguments belong after --.");
|
|
120
120
|
} else if error.contains("managed launcher refuses a different ambient tmux server") {
|
|
121
121
|
payload.action = String::from(
|
|
122
|
-
"
|
|
122
|
+
"Leave the current tmux client before retrying. Add --allow-nested-attach before -- only when nested attachment is intentional.",
|
|
123
123
|
);
|
|
124
124
|
}
|
|
125
125
|
payload
|
|
@@ -7,6 +7,18 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
7
7
|
|
|
8
8
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
|
9
9
|
|
|
10
|
+
#[test]
|
|
11
|
+
fn cursor_effort_is_rejected_by_shared_admission_for_every_level() {
|
|
12
|
+
for effort in ["low", "medium", "high", "xhigh", "max", "ultra"] {
|
|
13
|
+
let role = write_tmp("cursor.md", &format!("---\nprovider: cursor_agent\neffort: {effort}\n---\nRole\n"));
|
|
14
|
+
let error = compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace")
|
|
15
|
+
.err()
|
|
16
|
+
.expect("Cursor must reject effort before command construction");
|
|
17
|
+
assert!(error.to_string().contains("cursor_agent does not support effort; the Cursor CLI has no --effort flag"));
|
|
18
|
+
fs::remove_dir_all(role.parent().unwrap()).unwrap();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
fn unique_base() -> PathBuf {
|
|
11
23
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
12
24
|
std::env::temp_dir().join(format!("ta_rs_compile_{}_{}", std::process::id(), n))
|
|
@@ -590,15 +590,6 @@ fn compile_role_agent_with_mode(
|
|
|
590
590
|
effort.as_str()
|
|
591
591
|
)));
|
|
592
592
|
}
|
|
593
|
-
if provider_enum == Provider::CursorAgent {
|
|
594
|
-
return Err(ModelError::Validation(format!(
|
|
595
|
-
"{}: cursor_agent does not support effort '{}'. \
|
|
596
|
-
The Cursor CLI has no `--effort` flag; the framework must not drop the field and still launch. \
|
|
597
|
-
Remove effort from the role file (do not map it into `--model[effort=]` until that form is measured).",
|
|
598
|
-
role_path.display(),
|
|
599
|
-
effort.as_str()
|
|
600
|
-
)));
|
|
601
|
-
}
|
|
602
593
|
agent_items.push(("effort", Value::Str(effort.as_str().to_string())));
|
|
603
594
|
}
|
|
604
595
|
Ok(CompiledRole {
|
|
@@ -403,6 +403,11 @@ pub(crate) fn leader_env_for_identity(
|
|
|
403
403
|
"1".to_string(),
|
|
404
404
|
);
|
|
405
405
|
}
|
|
406
|
+
// Managed providers run after `sh -lc`; restore the launcher's native
|
|
407
|
+
// executable lookup path at invocation, not just in the tmux client env.
|
|
408
|
+
if let Some(path) = std::env::var_os("PATH").and_then(|path| path.into_string().ok()) {
|
|
409
|
+
leader_env.insert("PATH".to_string(), path);
|
|
410
|
+
}
|
|
406
411
|
leader_env
|
|
407
412
|
}
|
|
408
413
|
|
|
@@ -291,6 +291,27 @@ fn copilot_leader_env_disables_terminal_title_only_for_copilot() {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
#[test]
|
|
295
|
+
#[serial_test::serial(env)]
|
|
296
|
+
fn leader_env_preserves_native_path_for_managed_shell_wrapper() {
|
|
297
|
+
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
298
|
+
let native_path = "/tmp/native leader/bin:/usr/bin:/bin";
|
|
299
|
+
let _env = EnvGuard::apply(&[("PATH", Some(native_path))]);
|
|
300
|
+
let ws = p2_temp_ws("native_path");
|
|
301
|
+
let identity = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
|
|
302
|
+
let env = leader_env_for_identity(Provider::Pi, &identity);
|
|
303
|
+
assert_eq!(env.get("PATH").map(String::as_str), Some(native_path));
|
|
304
|
+
let command = crate::tmux_backend::leader_shell_wrapper_command(
|
|
305
|
+
&["pi".to_string(), "--name".to_string(), "leader".to_string()],
|
|
306
|
+
&ws, &env, &[], "pi",
|
|
307
|
+
);
|
|
308
|
+
assert!(command.contains("PATH='/tmp/native leader/bin:/usr/bin:/bin'"));
|
|
309
|
+
assert!(command.find("PATH=").unwrap() < command.find(" pi --name leader").unwrap());
|
|
310
|
+
assert_eq!(env.get("TEAM_AGENT_TEAM_ID").map(String::as_str), Some(identity.team_id.as_str()));
|
|
311
|
+
let _unset = EnvGuard::apply(&[("PATH", None)]);
|
|
312
|
+
assert!(!leader_env_for_identity(Provider::Pi, &identity).contains_key("PATH"));
|
|
313
|
+
}
|
|
314
|
+
|
|
294
315
|
// ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
|
|
295
316
|
// Lock CORRECT Python v0.2.11 leader-identity behavior the contracts missed.
|
|
296
317
|
// Golden re-probed via /tmp/probe_p2_leader.py vs team-agent-public @ 439bef8
|
|
@@ -476,26 +476,62 @@ pub(super) fn annotate_persisted_team_depth(
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
/// ---
|
|
479
|
-
/// purpose:
|
|
480
|
-
/// returns:
|
|
479
|
+
/// purpose: 判断目标团队是否已有 worker runtime,已证明的 Leader-only 登记不算初始化
|
|
480
|
+
/// returns: 未初始化的有效 Leader-only 登记为 false;已有或无法分类的目标 runtime 为 true
|
|
481
481
|
/// ---
|
|
482
482
|
pub(super) fn runtime_state_has_quick_start_team(state: &serde_json::Value, team: &str) -> bool {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
483
|
+
let entry = state
|
|
484
|
+
.get("teams")
|
|
485
|
+
.and_then(serde_json::Value::as_object)
|
|
486
|
+
.and_then(|teams| teams.get(team));
|
|
487
|
+
if let Some(entry) = entry {
|
|
488
|
+
return !is_quick_start_leader_bootstrap(entry);
|
|
489
|
+
}
|
|
490
|
+
// Legacy aliases still identify existing/ambiguous runtime, not a proven
|
|
491
|
+
// canonical bootstrap record. Do not grant fresh creation through an alias.
|
|
492
|
+
if state
|
|
493
|
+
.get("teams")
|
|
494
|
+
.and_then(serde_json::Value::as_object)
|
|
495
|
+
.is_some_and(|teams| teams.values().any(|entry| json_team_identity_matches(entry, team)))
|
|
496
|
+
{
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
let matches = explicit_active_team_key(state).as_deref() == Some(team)
|
|
493
500
|
|| crate::state::projection::team_state_key(state) == team
|
|
494
501
|
|| json_team_identity_matches(state, team)
|
|
495
502
|
|| state
|
|
496
503
|
.get("session_name")
|
|
497
504
|
.and_then(serde_json::Value::as_str)
|
|
498
|
-
.is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
|
|
505
|
+
.is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team));
|
|
506
|
+
matches && !is_quick_start_leader_bootstrap(state)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// An empty agent table alone is not fresh: stopped/removed/partial teams keep
|
|
510
|
+
// their spec, session or task history. Only a coherent attached leader binding
|
|
511
|
+
// with no worker initialization evidence may proceed through first creation.
|
|
512
|
+
pub(super) fn is_quick_start_leader_bootstrap(state: &serde_json::Value) -> bool {
|
|
513
|
+
if !state.is_object()
|
|
514
|
+
|| state.get("teams").is_some_and(|teams| !teams.is_object())
|
|
515
|
+
|| !state.get("agents").and_then(serde_json::Value::as_object).is_some_and(|agents| agents.is_empty())
|
|
516
|
+
|| state.get("spec_path").is_some()
|
|
517
|
+
|| state.get("team_dir").is_some_and(|dir| !dir.is_null())
|
|
518
|
+
|| state.get("session_name").is_some_and(|session| !session.is_null())
|
|
519
|
+
|| state.get("tasks").is_some_and(|tasks| {
|
|
520
|
+
!tasks.as_object().is_some_and(|tasks| tasks.is_empty())
|
|
521
|
+
&& !tasks.as_array().is_some_and(|tasks| tasks.is_empty())
|
|
522
|
+
})
|
|
523
|
+
|| state.get("status").is_some_and(|status| status.as_str() != Some("alive"))
|
|
524
|
+
|| state.get("archived_at").is_some_and(|value| !value.is_null())
|
|
525
|
+
{
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
let owner = state.get("team_owner");
|
|
529
|
+
let receiver = state.get("leader_receiver");
|
|
530
|
+
let owner_pane = owner.and_then(|owner| owner.get("pane_id")).and_then(serde_json::Value::as_str).filter(|pane| !pane.is_empty());
|
|
531
|
+
let receiver_pane = receiver.and_then(|receiver| receiver.get("pane_id")).and_then(serde_json::Value::as_str);
|
|
532
|
+
owner_pane.is_some()
|
|
533
|
+
&& owner_pane == receiver_pane
|
|
534
|
+
&& receiver.and_then(|receiver| receiver.get("status")).and_then(serde_json::Value::as_str) == Some("attached")
|
|
499
535
|
}
|
|
500
536
|
|
|
501
537
|
/// ---
|
|
@@ -513,3 +549,126 @@ pub(super) fn json_team_identity_matches(state: &serde_json::Value, team: &str)
|
|
|
513
549
|
.and_then(serde_json::Value::as_str)
|
|
514
550
|
.is_some_and(|value| value == team)
|
|
515
551
|
}
|
|
552
|
+
|
|
553
|
+
#[cfg(test)]
|
|
554
|
+
mod quick_start_runtime_identity_tests {
|
|
555
|
+
use super::runtime_state_has_quick_start_team;
|
|
556
|
+
use serde_json::{json, Value};
|
|
557
|
+
|
|
558
|
+
fn leader_bootstrap() -> Value {
|
|
559
|
+
json!({
|
|
560
|
+
"agents": {},
|
|
561
|
+
"session_name": null,
|
|
562
|
+
"tasks": {},
|
|
563
|
+
"team_owner": {"pane_id": "%0", "provider": "pi", "owner_epoch": 1},
|
|
564
|
+
"leader_receiver": {"pane_id": "%0", "status": "attached", "session_name": "team-agent-leader-pi-fixture"},
|
|
565
|
+
"leader_client": {"diagnostic_only": true}
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#[test]
|
|
570
|
+
fn current_launcher_seed_is_not_initialized_and_is_not_mutated() {
|
|
571
|
+
let seed = leader_bootstrap();
|
|
572
|
+
let state = json!({"active_team_key": "current", "team_key": "current", "teams": {"current": seed}});
|
|
573
|
+
let before = state.clone();
|
|
574
|
+
assert!(!runtime_state_has_quick_start_team(&state, "current"));
|
|
575
|
+
assert_eq!(state, before);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
#[test]
|
|
579
|
+
fn real_native_managed_launcher_empty_array_shape_is_fresh_and_unchanged() {
|
|
580
|
+
let mut entry = leader_bootstrap();
|
|
581
|
+
entry["tasks"] = json!([]);
|
|
582
|
+
entry["is_external_leader"] = json!(false);
|
|
583
|
+
entry["team_key"] = json!("current");
|
|
584
|
+
entry["tmux_socket"] = json!("/private/tmp/fixture-native-leader");
|
|
585
|
+
entry["owner_epoch"] = json!(1);
|
|
586
|
+
entry["team_owner"]["machine_fingerprint"] = json!("");
|
|
587
|
+
entry["team_owner"]["leader_session_uuid"] = json!("fixture-native-uuid");
|
|
588
|
+
entry["leader_receiver"]["owner_epoch"] = json!(1);
|
|
589
|
+
entry["leader_receiver"]["leader_session_uuid"] = json!("fixture-native-uuid");
|
|
590
|
+
let state = json!({
|
|
591
|
+
"agents": {}, "tasks": [], "session_name": null,
|
|
592
|
+
"active_team_key": "current", "team_key": "current",
|
|
593
|
+
"teams": {"current": entry}
|
|
594
|
+
});
|
|
595
|
+
let before = state.clone();
|
|
596
|
+
assert!(!runtime_state_has_quick_start_team(&state, "current"));
|
|
597
|
+
assert_eq!(state, before);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
#[test]
|
|
601
|
+
fn legacy_flat_leader_binding_can_initialize() {
|
|
602
|
+
let mut state = leader_bootstrap();
|
|
603
|
+
state["active_team_key"] = json!("current");
|
|
604
|
+
assert!(!runtime_state_has_quick_start_team(&state, "current"));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
#[test]
|
|
608
|
+
fn stopped_removed_partial_and_unknown_teams_remain_existing() {
|
|
609
|
+
let cases = [
|
|
610
|
+
("agents", json!({"worker": {"status": "stopped"}})),
|
|
611
|
+
("spec_path", json!("/tmp/current/team.spec.yaml")),
|
|
612
|
+
("spec_path", Value::Null),
|
|
613
|
+
("team_dir", json!("/tmp/.team/current")),
|
|
614
|
+
("session_name", json!("team-current")),
|
|
615
|
+
("session_name", json!("team-agent-leader-stale-worker-field")),
|
|
616
|
+
("tasks", json!({"finished_task": {"status": "done"}})),
|
|
617
|
+
("tasks", json!([{"status": "done"}])),
|
|
618
|
+
("tasks", Value::Null),
|
|
619
|
+
("tasks", json!("unknown")),
|
|
620
|
+
("tasks", json!(false)),
|
|
621
|
+
("tasks", json!(0)),
|
|
622
|
+
("status", json!("stopped")),
|
|
623
|
+
("archived_at", json!("2026-10-08")),
|
|
624
|
+
("agents", Value::Null),
|
|
625
|
+
("agents", json!([])),
|
|
626
|
+
("team_owner", Value::Null),
|
|
627
|
+
("leader_receiver", json!({"pane_id": "%99", "status": "attached"})),
|
|
628
|
+
("leader_receiver", json!({"pane_id": "%0", "status": "pending"})),
|
|
629
|
+
];
|
|
630
|
+
for (key, value) in cases {
|
|
631
|
+
let mut entry = leader_bootstrap();
|
|
632
|
+
entry[key] = value;
|
|
633
|
+
let state = json!({"active_team_key": "current", "teams": {"current": entry}});
|
|
634
|
+
assert!(runtime_state_has_quick_start_team(&state, "current"), "must protect {key}: {state}");
|
|
635
|
+
}
|
|
636
|
+
for entry in [json!({}), Value::Null, json!({"agents": {}, "session_name": null})] {
|
|
637
|
+
let state = json!({"teams": {"current": entry}});
|
|
638
|
+
assert!(runtime_state_has_quick_start_team(&state, "current"));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#[test]
|
|
643
|
+
fn malformed_team_tables_do_not_allow_flat_bootstrap_to_bypass_the_guard() {
|
|
644
|
+
for teams in [Value::Null, json!([]), json!("damaged")] {
|
|
645
|
+
let mut state = leader_bootstrap();
|
|
646
|
+
state["active_team_key"] = json!("current");
|
|
647
|
+
state["teams"] = teams;
|
|
648
|
+
assert!(runtime_state_has_quick_start_team(&state, "current"));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#[test]
|
|
653
|
+
fn only_the_requested_team_supplies_initialization_evidence() {
|
|
654
|
+
let state = json!({
|
|
655
|
+
"active_team_key": "other", "spec_path": "/tmp/other/spec.yaml",
|
|
656
|
+
"session_name": "team-other", "agents": {"other_worker": {}},
|
|
657
|
+
"teams": {
|
|
658
|
+
"other": {"spec_path": "/tmp/other/spec.yaml", "agents": {"other_worker": {}}},
|
|
659
|
+
"current": leader_bootstrap()
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
assert!(!runtime_state_has_quick_start_team(&state, "current"));
|
|
663
|
+
assert!(runtime_state_has_quick_start_team(&state, "other"));
|
|
664
|
+
assert!(!runtime_state_has_quick_start_team(&state, "new-team"));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
#[test]
|
|
668
|
+
fn ambiguous_legacy_identity_is_not_fresh() {
|
|
669
|
+
let mut first = leader_bootstrap();
|
|
670
|
+
first["name"] = json!("current");
|
|
671
|
+
let state = json!({"teams": {"first": first, "second": {"name": "current"}}});
|
|
672
|
+
assert!(runtime_state_has_quick_start_team(&state, "current"));
|
|
673
|
+
}
|
|
674
|
+
}
|