@team-agent/installer 0.5.114 → 0.5.115
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/emit.rs +2 -2
- package/crates/team-agent/src/cli/mod.rs +12 -7
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/contract_runtime/backend.rs +408 -95
- package/crates/team-agent/src/contract_runtime/commands.rs +245 -0
- package/crates/team-agent/src/contract_runtime/config.rs +39 -8
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +186 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +95 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +171 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +160 -0
- package/crates/team-agent/src/leader/start.rs +93 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
- package/crates/team-agent/src/lifecycle/launch.rs +6 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +23 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +34 -0
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/package.json +4 -4
|
@@ -1,9 +1,140 @@
|
|
|
1
|
-
//! The sole new-runtime provider
|
|
1
|
+
//! The sole new-runtime provider registry. This is not an admission claim:
|
|
2
2
|
//! the descriptor's Unverified gates still apply to each requested operation.
|
|
3
|
-
use
|
|
4
|
-
use
|
|
3
|
+
use std::collections::BTreeMap;
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use team_agent_contract::contract::{
|
|
6
|
+
descriptor::{PromptCarrier, ProviderDescriptor, ToolNaming},
|
|
7
|
+
hooks::{PlanHook, ProviderHooks},
|
|
8
|
+
plan::{LaunchPlan, ResolvedLaunch},
|
|
9
|
+
types::{ContractError, Support},
|
|
10
|
+
};
|
|
11
|
+
use team_agent_contract::kiro::{self, KiroAdapter, KIRO_DESCRIPTOR};
|
|
12
|
+
use team_agent_contract::providers::cursor::{self, CursorAdapter, CURSOR_DESCRIPTOR};
|
|
13
|
+
use team_agent_contract::runtime::{delivery::ControlDefinition, native_panel::NativePanelPolicy};
|
|
5
14
|
|
|
6
|
-
const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR];
|
|
15
|
+
const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR];
|
|
16
|
+
|
|
17
|
+
/// Leaf selection stays here; the framework, lifecycle and outbox remain shared.
|
|
18
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
19
|
+
pub(super) enum NativeProvider {
|
|
20
|
+
Kiro,
|
|
21
|
+
Cursor,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl NativeProvider {
|
|
25
|
+
pub fn descriptor(self) -> &'static ProviderDescriptor {
|
|
26
|
+
match self {
|
|
27
|
+
Self::Kiro => &KIRO_DESCRIPTOR,
|
|
28
|
+
Self::Cursor => &CURSOR_DESCRIPTOR,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub fn action_budget(self) -> std::time::Duration {
|
|
33
|
+
// Kiro's existing budget is unchanged; Cursor's measured cold-ready
|
|
34
|
+
// path needs a larger finite bound, not an unbounded readiness wait.
|
|
35
|
+
std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor => 90 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub fn controls(self) -> &'static [ControlDefinition] {
|
|
39
|
+
match self {
|
|
40
|
+
Self::Kiro => kiro::interaction::CONTROLS,
|
|
41
|
+
Self::Cursor => cursor::interaction::CONTROLS,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// A terminal instruction bridge needs one durable, authorized first packet
|
|
46
|
+
/// before the native client can demonstrate its Team-tool registry.
|
|
47
|
+
pub fn first_business_bootstrap(self) -> bool {
|
|
48
|
+
matches!(self.descriptor().prompt.carrier, Support::Supported(PromptCarrier::RuntimeBridge))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Cursor has no measured neutral TUI tools panel. Its protocol binding
|
|
52
|
+
/// must come from the separately fenced native tool-roundtrip ledger.
|
|
53
|
+
pub fn uses_tool_roundtrips(self) -> bool {
|
|
54
|
+
matches!(self, Self::Cursor)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub fn tool_panel(self) -> Result<&'static NativePanelPolicy, ContractError> {
|
|
58
|
+
match self {
|
|
59
|
+
Self::Kiro => Ok(&kiro::interaction::TOOL_PANEL),
|
|
60
|
+
Self::Cursor => Err(ContractError::Unverified {
|
|
61
|
+
field: "Cursor native Team-tool inspector",
|
|
62
|
+
reason: cursor::UNVERIFIED,
|
|
63
|
+
}),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub fn server_key(self) -> Result<&'static str, ContractError> {
|
|
68
|
+
match self.descriptor().tool_names.naming.require("tool naming")? {
|
|
69
|
+
ToolNaming::StaticServer { key } => Ok(key),
|
|
70
|
+
ToolNaming::RuntimeBound => Err(ContractError::Invalid("runtime server-key receipt required")),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub fn adapter(
|
|
75
|
+
self,
|
|
76
|
+
executable: PathBuf,
|
|
77
|
+
arguments: Vec<String>,
|
|
78
|
+
environment: BTreeMap<String, String>,
|
|
79
|
+
) -> Result<NativeAdapter, ContractError> {
|
|
80
|
+
match self {
|
|
81
|
+
Self::Kiro => KiroAdapter::new(kiro::McpStdio {
|
|
82
|
+
executable, arguments, environment,
|
|
83
|
+
}).map(NativeAdapter::Kiro),
|
|
84
|
+
Self::Cursor => CursorAdapter::new(cursor::McpStdio {
|
|
85
|
+
executable, arguments, environment,
|
|
86
|
+
}).map(NativeAdapter::Cursor),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pub(super) enum NativeAdapter {
|
|
92
|
+
Kiro(KiroAdapter),
|
|
93
|
+
Cursor(CursorAdapter),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
impl NativeAdapter {
|
|
97
|
+
pub fn with_launch_context(
|
|
98
|
+
self,
|
|
99
|
+
project: PathBuf,
|
|
100
|
+
runtime: Option<cursor::native::RuntimeInstallation>,
|
|
101
|
+
) -> Result<Self, ContractError> {
|
|
102
|
+
match self {
|
|
103
|
+
Self::Kiro(adapter) if runtime.is_none() => Ok(Self::Kiro(adapter)),
|
|
104
|
+
Self::Kiro(_) => Err(ContractError::Mismatch("native runtime provider")),
|
|
105
|
+
Self::Cursor(adapter) => {
|
|
106
|
+
let adapter = adapter.with_project(project)?;
|
|
107
|
+
Ok(Self::Cursor(match runtime {
|
|
108
|
+
Some(runtime) => adapter.with_runtime(runtime),
|
|
109
|
+
None => adapter,
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pub fn hooks(&self) -> ProviderHooks<'_> {
|
|
116
|
+
match self {
|
|
117
|
+
Self::Kiro(adapter) => adapter.hooks(),
|
|
118
|
+
Self::Cursor(adapter) => adapter.hooks(),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
pub fn plan(&self, request: &ResolvedLaunch) -> Result<LaunchPlan, ContractError> {
|
|
123
|
+
match self {
|
|
124
|
+
Self::Kiro(adapter) => adapter.plan(request),
|
|
125
|
+
Self::Cursor(adapter) => adapter.plan(request),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pub(super) fn provider(key: &str) -> Result<NativeProvider, ContractError> {
|
|
131
|
+
let descriptor = descriptor(key).ok_or(ContractError::Invalid("unregistered contract provider"))?;
|
|
132
|
+
match descriptor.identity.id {
|
|
133
|
+
"kiro" => Ok(NativeProvider::Kiro),
|
|
134
|
+
"cursor" => Ok(NativeProvider::Cursor),
|
|
135
|
+
_ => Err(ContractError::Invalid("contract provider dispatcher")),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
7
138
|
|
|
8
139
|
pub(crate) fn descriptors() -> &'static [&'static ProviderDescriptor] {
|
|
9
140
|
DESCRIPTORS
|
|
@@ -28,3 +159,39 @@ pub fn recognizes(key: &str) -> bool {
|
|
|
28
159
|
.any(|alias| alias.eq_ignore_ascii_case(key))
|
|
29
160
|
})
|
|
30
161
|
}
|
|
162
|
+
|
|
163
|
+
#[cfg(test)]
|
|
164
|
+
mod tests {
|
|
165
|
+
use super::*;
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn canonical_cursor_does_not_reclassify_legacy_names() {
|
|
169
|
+
assert_eq!(provider("cursor").unwrap(), NativeProvider::Cursor);
|
|
170
|
+
assert_eq!(provider("kiro").unwrap(), NativeProvider::Kiro);
|
|
171
|
+
for legacy in ["cursor_agent", "agent", "pi", "codex"] {
|
|
172
|
+
assert!(descriptor(legacy).is_none());
|
|
173
|
+
assert!(!recognizes(legacy));
|
|
174
|
+
assert!(provider(legacy).is_err());
|
|
175
|
+
}
|
|
176
|
+
assert!(recognizes(" Cursor "));
|
|
177
|
+
assert!(descriptor("Cursor").is_none());
|
|
178
|
+
for descriptor in descriptors() {
|
|
179
|
+
assert_eq!(provider(descriptor.identity.id).unwrap().descriptor(), *descriptor);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn cursor_has_no_kiro_control_or_tool_inspector_fallback() {
|
|
185
|
+
assert!(NativeProvider::Cursor.controls().is_empty());
|
|
186
|
+
assert!(matches!(NativeProvider::Cursor.tool_panel(), Err(ContractError::Unverified { .. })));
|
|
187
|
+
assert_eq!(NativeProvider::Cursor.server_key().unwrap(), "team");
|
|
188
|
+
assert!(NativeProvider::Cursor.first_business_bootstrap());
|
|
189
|
+
assert!(NativeProvider::Cursor.uses_tool_roundtrips());
|
|
190
|
+
assert!(!NativeProvider::Kiro.first_business_bootstrap());
|
|
191
|
+
assert!(!NativeProvider::Kiro.uses_tool_roundtrips());
|
|
192
|
+
let kiro_panel = NativeProvider::Kiro.tool_panel().unwrap();
|
|
193
|
+
assert_eq!(kiro_panel.server_key, NativeProvider::Kiro.server_key().unwrap());
|
|
194
|
+
assert_eq!(kiro_panel.policy_sha256, kiro::interaction::POLICY);
|
|
195
|
+
assert!(!NativeProvider::Kiro.controls().is_empty());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
//! Stable per-seat primary cwd. The project stays an additional directory;
|
|
2
|
+
//! private MCP files never overwrite a shared project or another seat's config.
|
|
3
|
+
use std::io::{Read, Write};
|
|
4
|
+
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
|
5
|
+
use std::path::Path;
|
|
6
|
+
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
use team_agent_contract::contract::{session::CwdIdentity, types::SeatId};
|
|
9
|
+
use team_agent_contract::host::process::resolve_cwd;
|
|
10
|
+
|
|
11
|
+
use super::backend::{root_for, BackendError, Binding};
|
|
12
|
+
|
|
13
|
+
#[derive(Serialize, Deserialize)]
|
|
14
|
+
#[serde(deny_unknown_fields)]
|
|
15
|
+
struct CwdRecord {
|
|
16
|
+
version: u32,
|
|
17
|
+
seat: SeatId,
|
|
18
|
+
cwd: CwdIdentity,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Requires an already owned framework root. Existing unrecorded directories
|
|
22
|
+
/// are refused, not adopted. A replaced inode or any symlink is a hard error.
|
|
23
|
+
pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
|
|
24
|
+
let root = root_for(&binding.workspace.path, &binding.scope);
|
|
25
|
+
validate_directory(&root)?;
|
|
26
|
+
let parent = root.join("workspaces");
|
|
27
|
+
let path = parent.join(seat.as_str());
|
|
28
|
+
let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
|
|
29
|
+
match std::fs::symlink_metadata(&record_path) {
|
|
30
|
+
Ok(metadata) => {
|
|
31
|
+
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
|
32
|
+
return Err(BackendError::Metadata);
|
|
33
|
+
}
|
|
34
|
+
let mut file = std::fs::OpenOptions::new().read(true)
|
|
35
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(&record_path)?;
|
|
36
|
+
let opened = file.metadata()?;
|
|
37
|
+
if opened.dev() != metadata.dev() || opened.ino() != metadata.ino() {
|
|
38
|
+
return Err(BackendError::Metadata);
|
|
39
|
+
}
|
|
40
|
+
let mut bytes = Vec::new();
|
|
41
|
+
Read::by_ref(&mut file).take(65537).read_to_end(&mut bytes)?;
|
|
42
|
+
if bytes.len() > 65536 {
|
|
43
|
+
return Err(BackendError::Metadata);
|
|
44
|
+
}
|
|
45
|
+
let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
|
|
46
|
+
if record.version != 1 || record.seat != *seat || record.cwd.path != path {
|
|
47
|
+
return Err(BackendError::Metadata);
|
|
48
|
+
}
|
|
49
|
+
validate_directory(&parent)?;
|
|
50
|
+
validate_directory(&path)?;
|
|
51
|
+
if resolve_cwd(&path)? != record.cwd {
|
|
52
|
+
return Err(BackendError::Metadata);
|
|
53
|
+
}
|
|
54
|
+
return Ok(record.cwd);
|
|
55
|
+
}
|
|
56
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
57
|
+
Err(error) => return Err(error.into()),
|
|
58
|
+
}
|
|
59
|
+
match std::fs::symlink_metadata(&parent) {
|
|
60
|
+
Ok(_) => validate_directory(&parent)?,
|
|
61
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
62
|
+
std::fs::DirBuilder::new().mode(0o700).create(&parent)?;
|
|
63
|
+
}
|
|
64
|
+
Err(error) => return Err(error.into()),
|
|
65
|
+
}
|
|
66
|
+
// Exclusive create is deliberate: missing record + existing path is not
|
|
67
|
+
// enough evidence that this framework owns the previous incarnation.
|
|
68
|
+
std::fs::DirBuilder::new().mode(0o700).create(&path)?;
|
|
69
|
+
validate_directory(&path)?;
|
|
70
|
+
let cwd = resolve_cwd(&path)?;
|
|
71
|
+
let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
|
|
72
|
+
let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
|
|
73
|
+
let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
|
|
74
|
+
.mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
|
|
75
|
+
file.write_all(&bytes)?;
|
|
76
|
+
file.sync_all()?;
|
|
77
|
+
Ok(cwd)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn validate_directory(path: &Path) -> Result<(), BackendError> {
|
|
81
|
+
let metadata = std::fs::symlink_metadata(path)?;
|
|
82
|
+
if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
|
|
83
|
+
|| metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0
|
|
84
|
+
{
|
|
85
|
+
return Err(BackendError::Metadata);
|
|
86
|
+
}
|
|
87
|
+
Ok(())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[cfg(test)]
|
|
91
|
+
mod tests {
|
|
92
|
+
use super::*;
|
|
93
|
+
use std::path::PathBuf;
|
|
94
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
95
|
+
use team_agent_contract::contract::types::{Digest, ScopeId};
|
|
96
|
+
|
|
97
|
+
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
98
|
+
struct Fixture { path: PathBuf, binding: Binding }
|
|
99
|
+
impl Fixture {
|
|
100
|
+
fn new() -> Self {
|
|
101
|
+
let path = std::env::temp_dir().canonicalize().unwrap().join(format!(
|
|
102
|
+
"contract-cwd-{}-{}-{}", std::process::id(),
|
|
103
|
+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(),
|
|
104
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed),
|
|
105
|
+
));
|
|
106
|
+
std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
|
|
107
|
+
let binding = Binding {
|
|
108
|
+
version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
|
|
109
|
+
scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
|
|
110
|
+
candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
|
|
111
|
+
tmux: "/controlled/tmux".into(), transport_root: path.join("transport"),
|
|
112
|
+
};
|
|
113
|
+
let root = root_for(&path, &binding.scope);
|
|
114
|
+
std::fs::create_dir_all(root.parent().unwrap()).unwrap();
|
|
115
|
+
std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
|
|
116
|
+
Self { path, binding }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
impl Drop for Fixture {
|
|
120
|
+
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn stable_cwd_is_reused_and_distinct_seats_do_not_share_a_primary() {
|
|
125
|
+
let f = Fixture::new();
|
|
126
|
+
let a = SeatId::new("a").unwrap();
|
|
127
|
+
let b = SeatId::new("b").unwrap();
|
|
128
|
+
let first = prepare(&f.binding, &a).unwrap();
|
|
129
|
+
assert_eq!(prepare(&f.binding, &a).unwrap(), first);
|
|
130
|
+
let second = prepare(&f.binding, &b).unwrap();
|
|
131
|
+
assert_ne!(first.path, second.path);
|
|
132
|
+
assert_ne!(first.identity, second.identity);
|
|
133
|
+
assert!(!f.path.join(".cursor").exists());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[test]
|
|
137
|
+
fn replaced_inode_or_symlink_is_not_adopted_on_resume() {
|
|
138
|
+
let f = Fixture::new();
|
|
139
|
+
let seat = SeatId::new("worker").unwrap();
|
|
140
|
+
let original = prepare(&f.binding, &seat).unwrap();
|
|
141
|
+
let retained = original.path.with_extension("old");
|
|
142
|
+
std::fs::rename(&original.path, &retained).unwrap();
|
|
143
|
+
std::fs::DirBuilder::new().mode(0o700).create(&original.path).unwrap();
|
|
144
|
+
assert!(prepare(&f.binding, &seat).is_err());
|
|
145
|
+
std::fs::remove_dir(&original.path).unwrap();
|
|
146
|
+
std::os::unix::fs::symlink(&retained, &original.path).unwrap();
|
|
147
|
+
assert!(prepare(&f.binding, &seat).is_err());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[test]
|
|
151
|
+
fn unrecorded_existing_directory_is_not_claimed() {
|
|
152
|
+
let f = Fixture::new();
|
|
153
|
+
let root = root_for(&f.path, &f.binding.scope);
|
|
154
|
+
let parent = root.join("workspaces");
|
|
155
|
+
std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
|
|
156
|
+
std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
|
|
157
|
+
assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
|
|
158
|
+
assert!(!root.join("cwd-worker.json").exists());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -2140,10 +2140,8 @@ fn persist_exec_provider_leader_binding(
|
|
|
2140
2140
|
"active_team_key".to_string(),
|
|
2141
2141
|
serde_json::json!(identity.team_id.as_str()),
|
|
2142
2142
|
);
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
serde_json::json!(target.session.as_str()),
|
|
2146
|
-
);
|
|
2143
|
+
// The ambient session identifies the leader receiver, not workers.
|
|
2144
|
+
// Leave any existing worker session untouched, including unknown state.
|
|
2147
2145
|
obj.insert("tmux_endpoint".to_string(), serde_json::json!(socket));
|
|
2148
2146
|
obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
2149
2147
|
obj.insert(
|
|
@@ -3062,7 +3060,8 @@ mod tests {
|
|
|
3062
3060
|
execute_leader_plan_after_ambient_authority, format_launcher_failure,
|
|
3063
3061
|
handle_exec_provider_startup_prompts, is_interactive_shell_basename,
|
|
3064
3062
|
lock_down_window_rename, managed_client_argv, managed_leader_window_from_occupied,
|
|
3065
|
-
|
|
3063
|
+
persist_exec_provider_leader_binding, persist_managed_leader_binding,
|
|
3064
|
+
push_bounded_stderr, shlex_quote,
|
|
3066
3065
|
write_window_rename_lockdown_event, ManagedClientAttachMode, VerifiedAmbientPaneAuthority,
|
|
3067
3066
|
LEADER_STDERR_LIMIT,
|
|
3068
3067
|
};
|
|
@@ -3495,6 +3494,95 @@ mod tests {
|
|
|
3495
3494
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
3496
3495
|
}
|
|
3497
3496
|
|
|
3497
|
+
fn exec_binding_fixture(workspace: &Path) -> (LeaderStartPlan, VerifiedAmbientPaneAuthority) {
|
|
3498
|
+
let identity = LeaderIdentity {
|
|
3499
|
+
leader_session_uuid: LeaderSessionUuid::derive(
|
|
3500
|
+
"fp", &workspace.to_string_lossy(), "tester", "current",
|
|
3501
|
+
).unwrap(),
|
|
3502
|
+
leader_session_uuid_source: LeaderSessionUuidSource::Derived,
|
|
3503
|
+
machine_fingerprint: "fp".into(), workspace_abspath: workspace.into(),
|
|
3504
|
+
os_user: "tester".into(), team_id: TeamKey::new("current"),
|
|
3505
|
+
};
|
|
3506
|
+
let plan = LeaderStartPlan {
|
|
3507
|
+
mode: LeaderStartMode::ExecProvider, provider: Provider::Pi,
|
|
3508
|
+
workspace: workspace.into(), socket: LeaderLaunchSocket::Workspace,
|
|
3509
|
+
session_name: None, argv: vec![], provider_argv: vec!["pi".into()],
|
|
3510
|
+
leader_prompt: None, session_capture: None, leader_window: None,
|
|
3511
|
+
is_external_leader: false, leader_env: BTreeMap::new(),
|
|
3512
|
+
identity: Some(identity), detached: false,
|
|
3513
|
+
};
|
|
3514
|
+
let authority = VerifiedAmbientPaneAuthority {
|
|
3515
|
+
pane_id: PaneId::new("%77"),
|
|
3516
|
+
observed: PaneInfo {
|
|
3517
|
+
pane_id: PaneId::new("%77"), session: SessionName::new("cursor-gate-leader"),
|
|
3518
|
+
window_index: Some(0), window_name: Some(WindowName::new("pi")),
|
|
3519
|
+
pane_index: Some(0), tty: Some("/dev/ttys077".into()),
|
|
3520
|
+
current_command: Some("pi".into()), current_path: Some(workspace.into()),
|
|
3521
|
+
active: true, pane_pid: None, leader_env: BTreeMap::new(),
|
|
3522
|
+
},
|
|
3523
|
+
endpoint: "/private/tmp/fixture-exec-leader.sock".into(),
|
|
3524
|
+
tmux: "/private/tmp/fixture-exec-leader.sock,123,0".into(),
|
|
3525
|
+
};
|
|
3526
|
+
(plan, authority)
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
fn exec_binding_workspace() -> std::path::PathBuf {
|
|
3530
|
+
let workspace = std::env::temp_dir().join(format!("ta-exec-bootstrap-{}-{}",
|
|
3531
|
+
std::process::id(), std::time::SystemTime::now()
|
|
3532
|
+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
|
|
3533
|
+
std::fs::create_dir(&workspace).unwrap();
|
|
3534
|
+
workspace
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
#[test]
|
|
3538
|
+
fn exec_provider_non_prefixed_receiver_produces_fresh_quick_start_bootstrap() {
|
|
3539
|
+
let workspace = exec_binding_workspace();
|
|
3540
|
+
let (plan, authority) = exec_binding_fixture(&workspace);
|
|
3541
|
+
persist_exec_provider_leader_binding(&plan, &workspace, &authority, false).unwrap();
|
|
3542
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3543
|
+
let before = state.clone();
|
|
3544
|
+
assert!(state["session_name"].is_null());
|
|
3545
|
+
assert!(state["teams"]["current"]["session_name"].is_null());
|
|
3546
|
+
assert_eq!(state["teams"]["current"]["leader_receiver"]["session_name"], "cursor-gate-leader");
|
|
3547
|
+
assert!(!crate::lifecycle::launch::quick_start_has_runtime_for_test(&state, "current"));
|
|
3548
|
+
assert_eq!(state, before);
|
|
3549
|
+
std::fs::remove_dir_all(workspace).unwrap();
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
#[test]
|
|
3553
|
+
fn exec_provider_preserves_existing_workers_and_never_clears_runtime_markers() {
|
|
3554
|
+
for status in ["running", "stopped", "unknown"] {
|
|
3555
|
+
let workspace = exec_binding_workspace();
|
|
3556
|
+
let (plan, authority) = exec_binding_fixture(&workspace);
|
|
3557
|
+
let path = crate::state::persist::runtime_state_path(&workspace);
|
|
3558
|
+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
3559
|
+
std::fs::write(&path, serde_json::json!({
|
|
3560
|
+
"active_team_key":"current", "session_name":"team-existing-workers",
|
|
3561
|
+
"spec_path":"/fixture/spec.yml", "team_dir":"/fixture/roles",
|
|
3562
|
+
"agents":{"worker":{"agent_id":"worker","provider":"pi","status":status}},
|
|
3563
|
+
"tasks":[{"task_id":"existing-task"}],
|
|
3564
|
+
}).to_string()).unwrap();
|
|
3565
|
+
persist_exec_provider_leader_binding(&plan, &workspace, &authority, false).unwrap();
|
|
3566
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3567
|
+
for row in [&state, &state["teams"]["current"]] {
|
|
3568
|
+
assert_eq!(row["session_name"], "team-existing-workers");
|
|
3569
|
+
assert_eq!(row["agents"]["worker"]["status"], status);
|
|
3570
|
+
assert_eq!(row["spec_path"], "/fixture/spec.yml");
|
|
3571
|
+
assert_eq!(row["team_dir"], "/fixture/roles");
|
|
3572
|
+
assert_eq!(row["tasks"][0]["task_id"], "existing-task");
|
|
3573
|
+
}
|
|
3574
|
+
// Ownership writers update only the canonical team record, not the
|
|
3575
|
+
// legacy root view that retains the existing worker runtime.
|
|
3576
|
+
assert_eq!(
|
|
3577
|
+
state["teams"]["current"]["leader_receiver"]["session_name"],
|
|
3578
|
+
"cursor-gate-leader"
|
|
3579
|
+
);
|
|
3580
|
+
assert!(state["leader_receiver"].is_null());
|
|
3581
|
+
assert!(crate::lifecycle::launch::quick_start_has_runtime_for_test(&state, "current"));
|
|
3582
|
+
std::fs::remove_dir_all(workspace).unwrap();
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3498
3586
|
#[test]
|
|
3499
3587
|
#[serial_test::serial(env)]
|
|
3500
3588
|
fn default_exec_provider_persists_current_pane_binding_before_provider_exec() {
|
|
@@ -120,7 +120,12 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
123
|
-
|
|
123
|
+
// Bind the endpoint we use to observe the caller, not the worker server.
|
|
124
|
+
let caller_transport = caller_read_transport();
|
|
125
|
+
caller_transport
|
|
126
|
+
.as_deref()
|
|
127
|
+
.unwrap_or(self.transport)
|
|
128
|
+
.tmux_endpoint()
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
@@ -3128,6 +3133,99 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
3128
3133
|
})
|
|
3129
3134
|
}
|
|
3130
3135
|
|
|
3136
|
+
#[cfg(unix)]
|
|
3137
|
+
#[test]
|
|
3138
|
+
#[serial_test::serial(env)]
|
|
3139
|
+
fn runtime_binding_endpoint_matches_caller_socket_without_redirecting_workers() {
|
|
3140
|
+
let hermetic = HermeticTestEnv::enter("runtime-caller-endpoint");
|
|
3141
|
+
let workspace = hermetic.root();
|
|
3142
|
+
let caller_socket = workspace.join("leader.sock");
|
|
3143
|
+
// Select the real caller-read backend without depending on a host tmux
|
|
3144
|
+
// server. This marker is not evidence of pane liveness.
|
|
3145
|
+
std::fs::write(&caller_socket, "fixture endpoint; no tmux server").unwrap();
|
|
3146
|
+
let caller_endpoint = caller_socket.to_str().unwrap();
|
|
3147
|
+
let tmux = hermetic.with_env("TMUX", &format!("{caller_endpoint},1,0"));
|
|
3148
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%0");
|
|
3149
|
+
let worker_endpoint = "ta-fixture-workers";
|
|
3150
|
+
let transport = OfflineTransport::new()
|
|
3151
|
+
.with_tmux_endpoint(worker_endpoint)
|
|
3152
|
+
.with_pane_current_command("%0", "pi");
|
|
3153
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
3154
|
+
transport: &transport,
|
|
3155
|
+
last_failure_reason: None,
|
|
3156
|
+
cas_conflict: false,
|
|
3157
|
+
applied_grant: None,
|
|
3158
|
+
registry_receipt: None,
|
|
3159
|
+
frozen_owner: None,
|
|
3160
|
+
frozen_receiver: None,
|
|
3161
|
+
nonce_writer: None,
|
|
3162
|
+
};
|
|
3163
|
+
let endpoint = ops.tmux_endpoint().unwrap();
|
|
3164
|
+
assert_eq!(endpoint, caller_endpoint);
|
|
3165
|
+
assert_eq!(
|
|
3166
|
+
caller_read_transport().unwrap().tmux_endpoint().as_deref(),
|
|
3167
|
+
Some(endpoint.as_str())
|
|
3168
|
+
);
|
|
3169
|
+
assert_eq!(transport.tmux_endpoint().as_deref(), Some(worker_endpoint));
|
|
3170
|
+
let state = native_persisted_binding(workspace, caller_endpoint);
|
|
3171
|
+
let before = state.clone();
|
|
3172
|
+
assert!(persisted_binding_matches_verified_pane(
|
|
3173
|
+
&state,
|
|
3174
|
+
workspace,
|
|
3175
|
+
&PaneId::new("%0"),
|
|
3176
|
+
Provider::Pi,
|
|
3177
|
+
Some(&endpoint),
|
|
3178
|
+
));
|
|
3179
|
+
assert_eq!(state, before);
|
|
3180
|
+
drop(tmux);
|
|
3181
|
+
assert!(caller_read_transport().is_none());
|
|
3182
|
+
assert_eq!(ops.tmux_endpoint().as_deref(), Some(worker_endpoint));
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
#[cfg(unix)]
|
|
3186
|
+
#[test]
|
|
3187
|
+
#[serial_test::serial(env)]
|
|
3188
|
+
fn runtime_binding_endpoint_rejects_foreign_socket_with_same_pane_identity() {
|
|
3189
|
+
let hermetic = HermeticTestEnv::enter("runtime-foreign-endpoint");
|
|
3190
|
+
let workspace = hermetic.root();
|
|
3191
|
+
let caller_socket = workspace.join("leader.sock");
|
|
3192
|
+
std::fs::write(&caller_socket, "fixture endpoint; no tmux server").unwrap();
|
|
3193
|
+
let caller_endpoint = caller_socket.to_str().unwrap();
|
|
3194
|
+
let _tmux = hermetic.with_env("TMUX", &format!("{caller_endpoint},1,0"));
|
|
3195
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%0");
|
|
3196
|
+
let worker_endpoint = "ta-fixture-workers";
|
|
3197
|
+
let transport = OfflineTransport::new()
|
|
3198
|
+
.with_tmux_endpoint(worker_endpoint)
|
|
3199
|
+
.with_pane_current_command("%0", "pi");
|
|
3200
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
3201
|
+
transport: &transport,
|
|
3202
|
+
last_failure_reason: None,
|
|
3203
|
+
cas_conflict: false,
|
|
3204
|
+
applied_grant: None,
|
|
3205
|
+
registry_receipt: None,
|
|
3206
|
+
frozen_owner: None,
|
|
3207
|
+
frozen_receiver: None,
|
|
3208
|
+
nonce_writer: None,
|
|
3209
|
+
};
|
|
3210
|
+
let endpoint = ops.tmux_endpoint().unwrap();
|
|
3211
|
+
assert_eq!(endpoint, caller_endpoint);
|
|
3212
|
+
let foreign_socket = workspace.join("foreign").join("leader.sock");
|
|
3213
|
+
for socket in [worker_endpoint, foreign_socket.to_str().unwrap()] {
|
|
3214
|
+
let state = native_persisted_binding(workspace, socket);
|
|
3215
|
+
let before = state.clone();
|
|
3216
|
+
// Both servers may have %0; the shared owner/provider/UUID/epoch
|
|
3217
|
+
// cannot make a foreign endpoint authoritative.
|
|
3218
|
+
assert!(!persisted_binding_matches_verified_pane(
|
|
3219
|
+
&state,
|
|
3220
|
+
workspace,
|
|
3221
|
+
&PaneId::new("%0"),
|
|
3222
|
+
Provider::Pi,
|
|
3223
|
+
Some(&endpoint),
|
|
3224
|
+
));
|
|
3225
|
+
assert_eq!(state, before);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3131
3229
|
#[cfg(unix)]
|
|
3132
3230
|
#[test]
|
|
3133
3231
|
#[serial_test::serial(env)]
|
|
@@ -120,15 +120,13 @@ pub(super) fn persist_spawn_agent_state(
|
|
|
120
120
|
continue;
|
|
121
121
|
};
|
|
122
122
|
#[cfg(unix)]
|
|
123
|
-
if agent
|
|
123
|
+
if let Some(descriptor) = agent
|
|
124
124
|
.get("provider")
|
|
125
125
|
.and_then(Value::as_str)
|
|
126
|
-
.
|
|
127
|
-
crate::contract_runtime::registry::descriptor(provider).is_some()
|
|
128
|
-
})
|
|
126
|
+
.and_then(crate::contract_runtime::registry::descriptor)
|
|
129
127
|
{
|
|
130
128
|
let projected = if agent_is_paused(agent) {
|
|
131
|
-
serde_json::json!({"agent_id":id,"provider":
|
|
129
|
+
serde_json::json!({"agent_id":id,"provider":descriptor.identity.id,"runtime_family":"contract","status":"paused","paused":true,"contract_instance":null,"pane_id":null})
|
|
132
130
|
} else {
|
|
133
131
|
crate::contract_runtime::framework::project_seat(workspace, &team_id, id)
|
|
134
132
|
.map_err(|error| LifecycleError::StatePersist(error.to_string()))?
|
|
@@ -383,6 +383,12 @@ pub use worker_env::{apply_cursor_subscription_proxy_env, CursorProxyPresence};
|
|
|
383
383
|
|
|
384
384
|
mod identity;
|
|
385
385
|
pub(super) use identity::*;
|
|
386
|
+
|
|
387
|
+
// Test-only seam for the real leader producer -> unchanged bootstrap classifier.
|
|
388
|
+
#[cfg(test)]
|
|
389
|
+
pub(crate) fn quick_start_has_runtime_for_test(state: &serde_json::Value, team: &str) -> bool {
|
|
390
|
+
identity::runtime_state_has_quick_start_team(state, team)
|
|
391
|
+
}
|
|
386
392
|
pub(crate) use identity::{
|
|
387
393
|
extend_worker_env_unset_for_effort, provider_effort_event_if_dropped,
|
|
388
394
|
provider_effort_event_if_dropped_json, provider_effort_event_payload,
|
|
@@ -405,6 +405,14 @@ fn restart_with_selected_team_and_transport(
|
|
|
405
405
|
let spec =
|
|
406
406
|
rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
|
|
407
407
|
phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::CompileSpec);
|
|
408
|
+
// All native H2/model/session/cwd gates run before any legacy teardown.
|
|
409
|
+
// Their private sockets and Node images must not enter legacy selection.
|
|
410
|
+
#[cfg(unix)]
|
|
411
|
+
let native_restart = if crate::contract_runtime::framework::has_contract(&state) {
|
|
412
|
+
crate::contract_runtime::backend::prepare_restart(
|
|
413
|
+
&selected.run_workspace, &selected.team_key, &selected.team_dir, &state,
|
|
414
|
+
).map_err(|error| LifecycleError::Provider(error.to_string()))?
|
|
415
|
+
} else { Vec::new() };
|
|
408
416
|
// 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
|
|
409
417
|
let runtime_spec =
|
|
410
418
|
crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
|
|
@@ -1354,6 +1362,20 @@ fn restart_with_selected_team_and_transport(
|
|
|
1354
1362
|
attach_commands,
|
|
1355
1363
|
});
|
|
1356
1364
|
}
|
|
1365
|
+
let legacy_successful_agents = successful_agents.clone();
|
|
1366
|
+
#[cfg(unix)]
|
|
1367
|
+
for result in crate::contract_runtime::commands::restart_prepared(
|
|
1368
|
+
&selected.run_workspace, &selected.team_key, &mut state, native_restart,
|
|
1369
|
+
) {
|
|
1370
|
+
let decision = RestartedAgent {
|
|
1371
|
+
agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
|
|
1372
|
+
decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
|
|
1373
|
+
};
|
|
1374
|
+
match result.outcome {
|
|
1375
|
+
Ok(()) => successful_agents.push(decision),
|
|
1376
|
+
Err(error) => failed_agents.push(restart_failed_agent(&decision, "native_resume", error)),
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1357
1379
|
drop(lifecycle_lock);
|
|
1358
1380
|
let coordinator =
|
|
1359
1381
|
start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
|
|
@@ -1368,7 +1390,7 @@ fn restart_with_selected_team_and_transport(
|
|
|
1368
1390
|
&selected.run_workspace,
|
|
1369
1391
|
&state,
|
|
1370
1392
|
&session_name,
|
|
1371
|
-
&
|
|
1393
|
+
&legacy_successful_agents,
|
|
1372
1394
|
transport,
|
|
1373
1395
|
restart_readiness_deadline(readiness_deadline_ms),
|
|
1374
1396
|
restart_readiness_poll_interval(),
|