@team-agent/installer 0.5.114 → 0.5.116
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 +155 -19
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
- package/crates/team-agent/src/contract_runtime/commands.rs +326 -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 +218 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +228 -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/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
- package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +5 -1
- package/package.json +4 -4
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
//! Stable per-seat primary cwd outside project Git ancestry. Provider-specific
|
|
2
|
+
//! project access remains explicit; private MCP files never overwrite a shared
|
|
3
|
+
//! project or another seat's config, and old cwd incarnations are not migrated.
|
|
4
|
+
use std::io::{Read, Write};
|
|
5
|
+
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
|
6
|
+
use std::path::Path;
|
|
7
|
+
|
|
8
|
+
use serde::{Deserialize, Serialize};
|
|
9
|
+
use team_agent_contract::contract::{session::CwdIdentity, types::SeatId};
|
|
10
|
+
use team_agent_contract::host::process::resolve_cwd;
|
|
11
|
+
|
|
12
|
+
use super::backend::{root_for, BackendError, Binding};
|
|
13
|
+
|
|
14
|
+
#[derive(Serialize, Deserialize)]
|
|
15
|
+
#[serde(deny_unknown_fields)]
|
|
16
|
+
struct CwdRecord {
|
|
17
|
+
version: u32,
|
|
18
|
+
seat: SeatId,
|
|
19
|
+
cwd: CwdIdentity,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Requires an already owned framework root. Existing unrecorded directories
|
|
23
|
+
/// are refused, not adopted. A replaced inode or any symlink is a hard error.
|
|
24
|
+
pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
|
|
25
|
+
let root = root_for(&binding.workspace.path, &binding.scope);
|
|
26
|
+
validate_directory(&root)?;
|
|
27
|
+
let parent = binding.transport_root.join("workspaces");
|
|
28
|
+
let path = parent.join(seat.as_str());
|
|
29
|
+
let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
|
|
30
|
+
match std::fs::symlink_metadata(&record_path) {
|
|
31
|
+
Ok(metadata) => {
|
|
32
|
+
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
|
33
|
+
return Err(BackendError::Metadata);
|
|
34
|
+
}
|
|
35
|
+
let mut file = std::fs::OpenOptions::new().read(true)
|
|
36
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(&record_path)?;
|
|
37
|
+
let opened = file.metadata()?;
|
|
38
|
+
if opened.dev() != metadata.dev() || opened.ino() != metadata.ino() {
|
|
39
|
+
return Err(BackendError::Metadata);
|
|
40
|
+
}
|
|
41
|
+
let mut bytes = Vec::new();
|
|
42
|
+
Read::by_ref(&mut file).take(65537).read_to_end(&mut bytes)?;
|
|
43
|
+
if bytes.len() > 65536 {
|
|
44
|
+
return Err(BackendError::Metadata);
|
|
45
|
+
}
|
|
46
|
+
let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
|
|
47
|
+
// Old sessions stay pinned to their original project-local cwd.
|
|
48
|
+
// Only new allocations move outside Git; never relocate a resume.
|
|
49
|
+
let expected = match record.version {
|
|
50
|
+
1 => root.join("workspaces").join(seat.as_str()),
|
|
51
|
+
2 => {
|
|
52
|
+
validate_directory(&binding.transport_root)?;
|
|
53
|
+
reject_git_ancestors(&binding.transport_root)?;
|
|
54
|
+
path.clone()
|
|
55
|
+
}
|
|
56
|
+
_ => return Err(BackendError::Metadata),
|
|
57
|
+
};
|
|
58
|
+
if record.seat != *seat || record.cwd.path != expected {
|
|
59
|
+
return Err(BackendError::Metadata);
|
|
60
|
+
}
|
|
61
|
+
validate_directory(expected.parent().ok_or(BackendError::Metadata)?)?;
|
|
62
|
+
validate_directory(&expected)?;
|
|
63
|
+
if resolve_cwd(&expected)? != record.cwd {
|
|
64
|
+
return Err(BackendError::Metadata);
|
|
65
|
+
}
|
|
66
|
+
return Ok(record.cwd);
|
|
67
|
+
}
|
|
68
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
69
|
+
Err(error) => return Err(error.into()),
|
|
70
|
+
}
|
|
71
|
+
validate_directory(&binding.transport_root)?;
|
|
72
|
+
reject_git_ancestors(&binding.transport_root)?;
|
|
73
|
+
match std::fs::symlink_metadata(&parent) {
|
|
74
|
+
Ok(_) => validate_directory(&parent)?,
|
|
75
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
76
|
+
std::fs::DirBuilder::new().mode(0o700).create(&parent)?;
|
|
77
|
+
}
|
|
78
|
+
Err(error) => return Err(error.into()),
|
|
79
|
+
}
|
|
80
|
+
// Exclusive create is deliberate: missing record + existing path is not
|
|
81
|
+
// enough evidence that this framework owns the previous incarnation.
|
|
82
|
+
std::fs::DirBuilder::new().mode(0o700).create(&path)?;
|
|
83
|
+
validate_directory(&path)?;
|
|
84
|
+
let cwd = resolve_cwd(&path)?;
|
|
85
|
+
let record = CwdRecord { version: 2, seat: seat.clone(), cwd: cwd.clone() };
|
|
86
|
+
let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
|
|
87
|
+
let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
|
|
88
|
+
.mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
|
|
89
|
+
file.write_all(&bytes)?;
|
|
90
|
+
file.sync_all()?;
|
|
91
|
+
Ok(cwd)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Metadata only: handle both ordinary .git directories and worktree .git files;
|
|
95
|
+
// never read Git config, credentials, execute hooks, or assume /tmp is non-Git.
|
|
96
|
+
fn reject_git_ancestors(path: &Path) -> Result<(), BackendError> {
|
|
97
|
+
for ancestor in path.ancestors() {
|
|
98
|
+
match std::fs::symlink_metadata(ancestor.join(".git")) {
|
|
99
|
+
Ok(_) => return Err(BackendError::Metadata),
|
|
100
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
101
|
+
Err(error) => return Err(error.into()),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
Ok(())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn validate_directory(path: &Path) -> Result<(), BackendError> {
|
|
108
|
+
let metadata = std::fs::symlink_metadata(path)?;
|
|
109
|
+
if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
|
|
110
|
+
|| metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0
|
|
111
|
+
{
|
|
112
|
+
return Err(BackendError::Metadata);
|
|
113
|
+
}
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[cfg(test)]
|
|
118
|
+
mod tests {
|
|
119
|
+
use super::*;
|
|
120
|
+
use std::path::PathBuf;
|
|
121
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
122
|
+
use team_agent_contract::contract::types::{Digest, ScopeId};
|
|
123
|
+
|
|
124
|
+
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
125
|
+
struct Fixture { path: PathBuf, binding: Binding }
|
|
126
|
+
impl Fixture {
|
|
127
|
+
fn new() -> Self {
|
|
128
|
+
let path = std::env::temp_dir().canonicalize().unwrap().join(format!(
|
|
129
|
+
"contract-cwd-{}-{}-{}", std::process::id(),
|
|
130
|
+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(),
|
|
131
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed),
|
|
132
|
+
));
|
|
133
|
+
std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
|
|
134
|
+
let binding = Binding {
|
|
135
|
+
version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
|
|
136
|
+
scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
|
|
137
|
+
candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
|
|
138
|
+
tmux: "/controlled/tmux".into(), transport_root: path.with_extension("transport"),
|
|
139
|
+
};
|
|
140
|
+
std::fs::DirBuilder::new().mode(0o700).create(&binding.transport_root).unwrap();
|
|
141
|
+
let root = root_for(&path, &binding.scope);
|
|
142
|
+
std::fs::create_dir_all(root.parent().unwrap()).unwrap();
|
|
143
|
+
std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
|
|
144
|
+
Self { path, binding }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
impl Drop for Fixture {
|
|
148
|
+
fn drop(&mut self) {
|
|
149
|
+
let _ = std::fs::remove_dir_all(&self.path);
|
|
150
|
+
let _ = std::fs::remove_dir_all(&self.binding.transport_root);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[test]
|
|
155
|
+
fn stable_cwd_is_reused_and_distinct_seats_do_not_share_a_primary() {
|
|
156
|
+
let f = Fixture::new();
|
|
157
|
+
let a = SeatId::new("a").unwrap();
|
|
158
|
+
let b = SeatId::new("b").unwrap();
|
|
159
|
+
let first = prepare(&f.binding, &a).unwrap();
|
|
160
|
+
assert_eq!(prepare(&f.binding, &a).unwrap(), first);
|
|
161
|
+
let second = prepare(&f.binding, &b).unwrap();
|
|
162
|
+
assert_ne!(first.path, second.path);
|
|
163
|
+
assert_ne!(first.identity, second.identity);
|
|
164
|
+
assert!(!f.path.join(".cursor").exists());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn replaced_inode_or_symlink_is_not_adopted_on_resume() {
|
|
169
|
+
let f = Fixture::new();
|
|
170
|
+
let seat = SeatId::new("worker").unwrap();
|
|
171
|
+
let original = prepare(&f.binding, &seat).unwrap();
|
|
172
|
+
let retained = original.path.with_extension("old");
|
|
173
|
+
std::fs::rename(&original.path, &retained).unwrap();
|
|
174
|
+
std::fs::DirBuilder::new().mode(0o700).create(&original.path).unwrap();
|
|
175
|
+
assert!(prepare(&f.binding, &seat).is_err());
|
|
176
|
+
std::fs::remove_dir(&original.path).unwrap();
|
|
177
|
+
std::os::unix::fs::symlink(&retained, &original.path).unwrap();
|
|
178
|
+
assert!(prepare(&f.binding, &seat).is_err());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#[test]
|
|
182
|
+
fn unrecorded_existing_directory_is_not_claimed() {
|
|
183
|
+
let f = Fixture::new();
|
|
184
|
+
let root = root_for(&f.path, &f.binding.scope);
|
|
185
|
+
let parent = f.binding.transport_root.join("workspaces");
|
|
186
|
+
std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
|
|
187
|
+
std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
|
|
188
|
+
assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
|
|
189
|
+
assert!(!root.join("cwd-worker.json").exists());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn new_primary_is_outside_project_git_ancestors() {
|
|
194
|
+
let f = Fixture::new();
|
|
195
|
+
std::fs::create_dir(f.path.join(".git")).unwrap();
|
|
196
|
+
let cwd = prepare(&f.binding, &SeatId::new("worker").unwrap()).unwrap();
|
|
197
|
+
assert!(cwd.path.starts_with(&f.binding.transport_root));
|
|
198
|
+
assert!(!cwd.path.starts_with(&f.path));
|
|
199
|
+
reject_git_ancestors(&cwd.path).unwrap();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[test]
|
|
203
|
+
fn existing_version_one_cwd_is_not_migrated() {
|
|
204
|
+
let f = Fixture::new();
|
|
205
|
+
let seat = SeatId::new("worker").unwrap();
|
|
206
|
+
let root = root_for(&f.path, &f.binding.scope);
|
|
207
|
+
let parent = root.join("workspaces");
|
|
208
|
+
let path = parent.join(seat.as_str());
|
|
209
|
+
std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
|
|
210
|
+
std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
|
|
211
|
+
let cwd = resolve_cwd(&path).unwrap();
|
|
212
|
+
let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
|
|
213
|
+
std::fs::write(root.join("cwd-worker.json"), serde_json::to_vec(&record).unwrap()).unwrap();
|
|
214
|
+
assert_eq!(prepare(&f.binding, &seat).unwrap(), cwd);
|
|
215
|
+
assert!(!f.binding.transport_root.join("workspaces").exists());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn git_directory_or_worktree_file_on_transport_ancestor_is_refused() {
|
|
220
|
+
let f = Fixture::new();
|
|
221
|
+
let marker = f.binding.transport_root.join(".git");
|
|
222
|
+
std::fs::write(&marker, b"gitdir: controlled-marker").unwrap();
|
|
223
|
+
assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
|
|
224
|
+
std::fs::remove_file(&marker).unwrap();
|
|
225
|
+
std::fs::create_dir(&marker).unwrap();
|
|
226
|
+
assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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,
|
|
@@ -307,6 +307,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
307
307
|
&window,
|
|
308
308
|
noop_pane.as_ref(),
|
|
309
309
|
)?;
|
|
310
|
+
state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(false);
|
|
310
311
|
let team_key = restart_projection_team_key(&state, team);
|
|
311
312
|
save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
|
|
312
313
|
if let Ok(spec) = load_team_spec(spec_workspace) {
|
|
@@ -1258,6 +1259,7 @@ pub(super) fn stop_agent_at_paths(
|
|
|
1258
1259
|
let _ = write_stop_drain_event(workspace, agent_id, &target_str, &drain_evidence);
|
|
1259
1260
|
}
|
|
1260
1261
|
mark_agent_stopped(&mut state, agent_id, agent, &window)?;
|
|
1262
|
+
state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(true);
|
|
1261
1263
|
let team_key = restart_projection_team_key(&state, team);
|
|
1262
1264
|
// golden operations.py:95: save_team_scoped_state (team projection) — NOT a raw save, so a
|
|
1263
1265
|
// multi-team workspace keeps the other teams' persisted runtime state instead of being clobbered.
|
|
@@ -1380,6 +1382,7 @@ pub(super) fn mark_agent_started(
|
|
|
1380
1382
|
}
|
|
1381
1383
|
}
|
|
1382
1384
|
agent.insert("status".to_string(), serde_json::json!("running"));
|
|
1385
|
+
agent.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
|
|
1383
1386
|
agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
|
|
1384
1387
|
agent.insert("window".to_string(), serde_json::json!(window));
|
|
1385
1388
|
agent.insert(
|
|
@@ -1741,6 +1744,7 @@ fn reset_agent_at_paths(
|
|
|
1741
1744
|
.and_then(serde_json::Value::as_object_mut)
|
|
1742
1745
|
{
|
|
1743
1746
|
obj.insert("spawn_epoch".to_string(), serde_json::json!(new_epoch));
|
|
1747
|
+
obj.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
|
|
1744
1748
|
}
|
|
1745
1749
|
let team_key = restart_projection_team_key(&state, team);
|
|
1746
1750
|
sync_restart_team_projections(&mut state, &team_key);
|