@team-agent/installer 0.5.115 → 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/mod.rs +143 -12
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
- package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
- package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
- package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
- package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
- 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
|
@@ -4,21 +4,33 @@ use std::collections::BTreeMap;
|
|
|
4
4
|
use std::path::PathBuf;
|
|
5
5
|
use team_agent_contract::contract::{
|
|
6
6
|
descriptor::{PromptCarrier, ProviderDescriptor, ToolNaming},
|
|
7
|
+
types::InstanceIdentity,
|
|
7
8
|
hooks::{PlanHook, ProviderHooks},
|
|
8
9
|
plan::{LaunchPlan, ResolvedLaunch},
|
|
9
10
|
types::{ContractError, Support},
|
|
10
11
|
};
|
|
11
12
|
use team_agent_contract::kiro::{self, KiroAdapter, KIRO_DESCRIPTOR};
|
|
12
13
|
use team_agent_contract::providers::cursor::{self, CursorAdapter, CURSOR_DESCRIPTOR};
|
|
13
|
-
use team_agent_contract::
|
|
14
|
+
use team_agent_contract::providers::grok_build::{self, GrokAdapter, GROK_BUILD_DESCRIPTOR};
|
|
15
|
+
use team_agent_contract::runtime::{
|
|
16
|
+
delivery::{ControlDefinition, StartupDefinition},
|
|
17
|
+
native_panel::{NativePanelPolicy, SessionPanelPolicy},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR, &GROK_BUILD_DESCRIPTOR];
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
#[derive(Clone)]
|
|
23
|
+
pub enum NativeRuntime {
|
|
24
|
+
Cursor(cursor::native::RuntimeInstallation),
|
|
25
|
+
GrokBuild(grok_build::native::RuntimeInstallation),
|
|
26
|
+
}
|
|
16
27
|
|
|
17
28
|
/// Leaf selection stays here; the framework, lifecycle and outbox remain shared.
|
|
18
29
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
19
30
|
pub(super) enum NativeProvider {
|
|
20
31
|
Kiro,
|
|
21
32
|
Cursor,
|
|
33
|
+
GrokBuild,
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
impl NativeProvider {
|
|
@@ -26,19 +38,21 @@ impl NativeProvider {
|
|
|
26
38
|
match self {
|
|
27
39
|
Self::Kiro => &KIRO_DESCRIPTOR,
|
|
28
40
|
Self::Cursor => &CURSOR_DESCRIPTOR,
|
|
41
|
+
Self::GrokBuild => &GROK_BUILD_DESCRIPTOR,
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
pub fn action_budget(self) -> std::time::Duration {
|
|
33
46
|
// Kiro's existing budget is unchanged; Cursor's measured cold-ready
|
|
34
47
|
// 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 })
|
|
48
|
+
std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor | Self::GrokBuild => 90 })
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
pub fn controls(self) -> &'static [ControlDefinition] {
|
|
39
52
|
match self {
|
|
40
53
|
Self::Kiro => kiro::interaction::CONTROLS,
|
|
41
54
|
Self::Cursor => cursor::interaction::CONTROLS,
|
|
55
|
+
Self::GrokBuild => grok_build::interaction::CONTROLS,
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -48,10 +62,24 @@ impl NativeProvider {
|
|
|
48
62
|
matches!(self.descriptor().prompt.carrier, Support::Supported(PromptCarrier::RuntimeBridge))
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
///
|
|
52
|
-
///
|
|
65
|
+
/// These providers use the separately fenced native tool-roundtrip ledger,
|
|
66
|
+
/// never a guessed neutral tools panel or another provider's inspector.
|
|
53
67
|
pub fn uses_tool_roundtrips(self) -> bool {
|
|
54
|
-
matches!(self, Self::Cursor)
|
|
68
|
+
matches!(self, Self::Cursor | Self::GrokBuild)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub fn startup(self) -> Option<&'static StartupDefinition> {
|
|
72
|
+
match self {
|
|
73
|
+
Self::GrokBuild => Some(&grok_build::interaction::STARTUP),
|
|
74
|
+
Self::Kiro | Self::Cursor => None,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pub fn session_panel(self) -> Option<&'static SessionPanelPolicy> {
|
|
79
|
+
match self {
|
|
80
|
+
Self::GrokBuild => Some(&grok_build::interaction::SESSION_PANEL),
|
|
81
|
+
Self::Kiro | Self::Cursor => None,
|
|
82
|
+
}
|
|
55
83
|
}
|
|
56
84
|
|
|
57
85
|
pub fn tool_panel(self) -> Result<&'static NativePanelPolicy, ContractError> {
|
|
@@ -61,9 +89,20 @@ impl NativeProvider {
|
|
|
61
89
|
field: "Cursor native Team-tool inspector",
|
|
62
90
|
reason: cursor::UNVERIFIED,
|
|
63
91
|
}),
|
|
92
|
+
Self::GrokBuild => Err(ContractError::Unverified {
|
|
93
|
+
field: "Grok Build native Team-tool inspector",
|
|
94
|
+
reason: grok_build::UNVERIFIED,
|
|
95
|
+
}),
|
|
64
96
|
}
|
|
65
97
|
}
|
|
66
98
|
|
|
99
|
+
pub fn server_key_for(self, identity: &InstanceIdentity) -> Result<String, ContractError> {
|
|
100
|
+
if self == Self::GrokBuild {
|
|
101
|
+
return Ok(grok_build::server_key(identity));
|
|
102
|
+
}
|
|
103
|
+
Ok(self.server_key()?.into())
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
pub fn server_key(self) -> Result<&'static str, ContractError> {
|
|
68
107
|
match self.descriptor().tool_names.naming.require("tool naming")? {
|
|
69
108
|
ToolNaming::StaticServer { key } => Ok(key),
|
|
@@ -84,6 +123,9 @@ impl NativeProvider {
|
|
|
84
123
|
Self::Cursor => CursorAdapter::new(cursor::McpStdio {
|
|
85
124
|
executable, arguments, environment,
|
|
86
125
|
}).map(NativeAdapter::Cursor),
|
|
126
|
+
Self::GrokBuild => GrokAdapter::new(grok_build::McpStdio {
|
|
127
|
+
executable, arguments, environment,
|
|
128
|
+
}).map(NativeAdapter::GrokBuild),
|
|
87
129
|
}
|
|
88
130
|
}
|
|
89
131
|
}
|
|
@@ -91,13 +133,14 @@ impl NativeProvider {
|
|
|
91
133
|
pub(super) enum NativeAdapter {
|
|
92
134
|
Kiro(KiroAdapter),
|
|
93
135
|
Cursor(CursorAdapter),
|
|
136
|
+
GrokBuild(GrokAdapter),
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
impl NativeAdapter {
|
|
97
140
|
pub fn with_launch_context(
|
|
98
141
|
self,
|
|
99
142
|
project: PathBuf,
|
|
100
|
-
runtime: Option<
|
|
143
|
+
runtime: Option<NativeRuntime>,
|
|
101
144
|
) -> Result<Self, ContractError> {
|
|
102
145
|
match self {
|
|
103
146
|
Self::Kiro(adapter) if runtime.is_none() => Ok(Self::Kiro(adapter)),
|
|
@@ -105,7 +148,16 @@ impl NativeAdapter {
|
|
|
105
148
|
Self::Cursor(adapter) => {
|
|
106
149
|
let adapter = adapter.with_project(project)?;
|
|
107
150
|
Ok(Self::Cursor(match runtime {
|
|
108
|
-
Some(runtime) => adapter.with_runtime(runtime),
|
|
151
|
+
Some(NativeRuntime::Cursor(runtime)) => adapter.with_runtime(runtime),
|
|
152
|
+
Some(_) => return Err(ContractError::Mismatch("native runtime provider")),
|
|
153
|
+
None => adapter,
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
156
|
+
Self::GrokBuild(adapter) => {
|
|
157
|
+
let adapter = adapter.with_project(project)?;
|
|
158
|
+
Ok(Self::GrokBuild(match runtime {
|
|
159
|
+
Some(NativeRuntime::GrokBuild(runtime)) => adapter.with_runtime(runtime),
|
|
160
|
+
Some(_) => return Err(ContractError::Mismatch("native runtime provider")),
|
|
109
161
|
None => adapter,
|
|
110
162
|
}))
|
|
111
163
|
}
|
|
@@ -116,6 +168,7 @@ impl NativeAdapter {
|
|
|
116
168
|
match self {
|
|
117
169
|
Self::Kiro(adapter) => adapter.hooks(),
|
|
118
170
|
Self::Cursor(adapter) => adapter.hooks(),
|
|
171
|
+
Self::GrokBuild(adapter) => adapter.hooks(),
|
|
119
172
|
}
|
|
120
173
|
}
|
|
121
174
|
|
|
@@ -123,6 +176,7 @@ impl NativeAdapter {
|
|
|
123
176
|
match self {
|
|
124
177
|
Self::Kiro(adapter) => adapter.plan(request),
|
|
125
178
|
Self::Cursor(adapter) => adapter.plan(request),
|
|
179
|
+
Self::GrokBuild(adapter) => adapter.plan(request),
|
|
126
180
|
}
|
|
127
181
|
}
|
|
128
182
|
}
|
|
@@ -132,6 +186,7 @@ pub(super) fn provider(key: &str) -> Result<NativeProvider, ContractError> {
|
|
|
132
186
|
match descriptor.identity.id {
|
|
133
187
|
"kiro" => Ok(NativeProvider::Kiro),
|
|
134
188
|
"cursor" => Ok(NativeProvider::Cursor),
|
|
189
|
+
"grok_build" => Ok(NativeProvider::GrokBuild),
|
|
135
190
|
_ => Err(ContractError::Invalid("contract provider dispatcher")),
|
|
136
191
|
}
|
|
137
192
|
}
|
|
@@ -168,11 +223,15 @@ mod tests {
|
|
|
168
223
|
fn canonical_cursor_does_not_reclassify_legacy_names() {
|
|
169
224
|
assert_eq!(provider("cursor").unwrap(), NativeProvider::Cursor);
|
|
170
225
|
assert_eq!(provider("kiro").unwrap(), NativeProvider::Kiro);
|
|
171
|
-
for legacy in ["cursor_agent", "agent", "pi", "codex"] {
|
|
226
|
+
for legacy in ["cursor_agent", "agent", "pi", "codex", "grok"] {
|
|
172
227
|
assert!(descriptor(legacy).is_none());
|
|
173
228
|
assert!(!recognizes(legacy));
|
|
174
229
|
assert!(provider(legacy).is_err());
|
|
175
230
|
}
|
|
231
|
+
assert_eq!(provider("grok_build").unwrap(), NativeProvider::GrokBuild);
|
|
232
|
+
assert!(recognizes(" Grok_Build "));
|
|
233
|
+
assert!(descriptor("Grok_Build").is_none());
|
|
234
|
+
assert!(GROK_BUILD_DESCRIPTOR.identity.aliases.is_empty());
|
|
176
235
|
assert!(recognizes(" Cursor "));
|
|
177
236
|
assert!(descriptor("Cursor").is_none());
|
|
178
237
|
for descriptor in descriptors() {
|
|
@@ -180,6 +239,24 @@ mod tests {
|
|
|
180
239
|
}
|
|
181
240
|
}
|
|
182
241
|
|
|
242
|
+
#[test]
|
|
243
|
+
fn grok_server_key_is_generation_bound_and_has_no_inspector_fallback() {
|
|
244
|
+
use team_agent_contract::contract::types::{Generation, InstanceId, ScopeId, SeatId};
|
|
245
|
+
let mut identity = InstanceIdentity {
|
|
246
|
+
scope: ScopeId::new("scope").unwrap(), seat: SeatId::new("worker").unwrap(),
|
|
247
|
+
instance: InstanceId::new("instance").unwrap(), generation: Generation(1),
|
|
248
|
+
};
|
|
249
|
+
let first = NativeProvider::GrokBuild.server_key_for(&identity).unwrap();
|
|
250
|
+
assert_eq!(first, grok_build::server_key(&identity));
|
|
251
|
+
identity.generation = Generation(2);
|
|
252
|
+
assert_ne!(first, NativeProvider::GrokBuild.server_key_for(&identity).unwrap());
|
|
253
|
+
assert!(NativeProvider::GrokBuild.server_key().is_err());
|
|
254
|
+
assert!(NativeProvider::GrokBuild.tool_panel().is_err());
|
|
255
|
+
assert!(NativeProvider::GrokBuild.startup().is_some());
|
|
256
|
+
assert!(NativeProvider::GrokBuild.session_panel().is_some());
|
|
257
|
+
assert!(NativeProvider::GrokBuild.uses_tool_roundtrips());
|
|
258
|
+
}
|
|
259
|
+
|
|
183
260
|
#[test]
|
|
184
261
|
fn cursor_has_no_kiro_control_or_tool_inspector_fallback() {
|
|
185
262
|
assert!(NativeProvider::Cursor.controls().is_empty());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//! Stable per-seat primary cwd
|
|
2
|
-
//! private MCP files never overwrite a shared
|
|
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.
|
|
3
4
|
use std::io::{Read, Write};
|
|
4
5
|
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
|
5
6
|
use std::path::Path;
|
|
@@ -23,7 +24,7 @@ struct CwdRecord {
|
|
|
23
24
|
pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
|
|
24
25
|
let root = root_for(&binding.workspace.path, &binding.scope);
|
|
25
26
|
validate_directory(&root)?;
|
|
26
|
-
let parent =
|
|
27
|
+
let parent = binding.transport_root.join("workspaces");
|
|
27
28
|
let path = parent.join(seat.as_str());
|
|
28
29
|
let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
|
|
29
30
|
match std::fs::symlink_metadata(&record_path) {
|
|
@@ -43,12 +44,23 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
|
|
|
43
44
|
return Err(BackendError::Metadata);
|
|
44
45
|
}
|
|
45
46
|
let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
|
|
46
|
-
|
|
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 {
|
|
47
59
|
return Err(BackendError::Metadata);
|
|
48
60
|
}
|
|
49
|
-
validate_directory(
|
|
50
|
-
validate_directory(&
|
|
51
|
-
if resolve_cwd(&
|
|
61
|
+
validate_directory(expected.parent().ok_or(BackendError::Metadata)?)?;
|
|
62
|
+
validate_directory(&expected)?;
|
|
63
|
+
if resolve_cwd(&expected)? != record.cwd {
|
|
52
64
|
return Err(BackendError::Metadata);
|
|
53
65
|
}
|
|
54
66
|
return Ok(record.cwd);
|
|
@@ -56,6 +68,8 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
|
|
|
56
68
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
57
69
|
Err(error) => return Err(error.into()),
|
|
58
70
|
}
|
|
71
|
+
validate_directory(&binding.transport_root)?;
|
|
72
|
+
reject_git_ancestors(&binding.transport_root)?;
|
|
59
73
|
match std::fs::symlink_metadata(&parent) {
|
|
60
74
|
Ok(_) => validate_directory(&parent)?,
|
|
61
75
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
@@ -68,7 +82,7 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
|
|
|
68
82
|
std::fs::DirBuilder::new().mode(0o700).create(&path)?;
|
|
69
83
|
validate_directory(&path)?;
|
|
70
84
|
let cwd = resolve_cwd(&path)?;
|
|
71
|
-
let record = CwdRecord { version:
|
|
85
|
+
let record = CwdRecord { version: 2, seat: seat.clone(), cwd: cwd.clone() };
|
|
72
86
|
let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
|
|
73
87
|
let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
|
|
74
88
|
.mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
|
|
@@ -77,6 +91,19 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
|
|
|
77
91
|
Ok(cwd)
|
|
78
92
|
}
|
|
79
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
|
+
|
|
80
107
|
fn validate_directory(path: &Path) -> Result<(), BackendError> {
|
|
81
108
|
let metadata = std::fs::symlink_metadata(path)?;
|
|
82
109
|
if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
|
|
@@ -108,8 +135,9 @@ mod tests {
|
|
|
108
135
|
version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
|
|
109
136
|
scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
|
|
110
137
|
candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
|
|
111
|
-
tmux: "/controlled/tmux".into(), transport_root: path.
|
|
138
|
+
tmux: "/controlled/tmux".into(), transport_root: path.with_extension("transport"),
|
|
112
139
|
};
|
|
140
|
+
std::fs::DirBuilder::new().mode(0o700).create(&binding.transport_root).unwrap();
|
|
113
141
|
let root = root_for(&path, &binding.scope);
|
|
114
142
|
std::fs::create_dir_all(root.parent().unwrap()).unwrap();
|
|
115
143
|
std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
|
|
@@ -117,7 +145,10 @@ mod tests {
|
|
|
117
145
|
}
|
|
118
146
|
}
|
|
119
147
|
impl Drop for Fixture {
|
|
120
|
-
fn drop(&mut self) {
|
|
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
|
+
}
|
|
121
152
|
}
|
|
122
153
|
|
|
123
154
|
#[test]
|
|
@@ -151,10 +182,47 @@ mod tests {
|
|
|
151
182
|
fn unrecorded_existing_directory_is_not_claimed() {
|
|
152
183
|
let f = Fixture::new();
|
|
153
184
|
let root = root_for(&f.path, &f.binding.scope);
|
|
154
|
-
let parent =
|
|
185
|
+
let parent = f.binding.transport_root.join("workspaces");
|
|
155
186
|
std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
|
|
156
187
|
std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
|
|
157
188
|
assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
|
|
158
189
|
assert!(!root.join("cwd-worker.json").exists());
|
|
159
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
|
+
}
|
|
160
228
|
}
|
|
@@ -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);
|
|
@@ -371,7 +371,15 @@ fn restart_with_selected_team_and_transport(
|
|
|
371
371
|
team: Some(selected.team_key.as_str()),
|
|
372
372
|
agent_id: None,
|
|
373
373
|
})?;
|
|
374
|
-
|
|
374
|
+
// Selection happened before the lifecycle lock. A stop-agent that won the
|
|
375
|
+
// lock must not be undone by the old running snapshot used by restart.
|
|
376
|
+
let mut state = crate::state::selector::resolve_active_team(
|
|
377
|
+
&selected.run_workspace,
|
|
378
|
+
Some(selected.team_key.as_str()),
|
|
379
|
+
crate::state::selector::SelectorMode::RequireSpec,
|
|
380
|
+
)
|
|
381
|
+
.map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
|
|
382
|
+
.state;
|
|
375
383
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
376
384
|
let topology_issue_ids = crate::topology::restart_dirty_topology_issue_ids(&state);
|
|
377
385
|
if !topology_issue_ids.is_empty() {
|
|
@@ -1092,50 +1100,23 @@ fn restart_with_selected_team_and_transport(
|
|
|
1092
1100
|
)?;
|
|
1093
1101
|
phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::SaveState);
|
|
1094
1102
|
if fatal_resume_failure {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
&successful_agents,
|
|
1100
|
-
&failed_agents,
|
|
1101
|
-
"fail",
|
|
1102
|
-
None,
|
|
1103
|
-
)?;
|
|
1104
|
-
return Ok(RestartReport::Failed {
|
|
1105
|
-
session_name,
|
|
1106
|
-
failed_agents,
|
|
1107
|
-
next_actions,
|
|
1108
|
-
attach_commands,
|
|
1109
|
-
});
|
|
1103
|
+
// The legacy cohort did not commit; its snapshot was restored above.
|
|
1104
|
+
// Do not promote tentative survivors, but still attempt the separately
|
|
1105
|
+
// admitted contract cohort before reporting the combined outcome.
|
|
1106
|
+
successful_agents.clear();
|
|
1110
1107
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
let
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
failed_agents,
|
|
1124
|
-
next_actions,
|
|
1125
|
-
attach_commands,
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
// Stamp the physical session birth after durable state. Replacing workers
|
|
1129
|
-
// in a retained session does not create a new session generation.
|
|
1130
|
-
if let Some(generation) = crate::state::projection::session_generation(&state) {
|
|
1131
|
-
transport
|
|
1132
|
-
.set_session_owner_with_generation(
|
|
1133
|
-
&session_name,
|
|
1134
|
-
&selected.run_workspace,
|
|
1135
|
-
&selected.team_key,
|
|
1136
|
-
generation,
|
|
1137
|
-
)
|
|
1138
|
-
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
1108
|
+
// A native-only active cohort has no legacy replacement session to stamp.
|
|
1109
|
+
if !successful_agents.is_empty() {
|
|
1110
|
+
if let Some(generation) = crate::state::projection::session_generation(&state) {
|
|
1111
|
+
transport
|
|
1112
|
+
.set_session_owner_with_generation(
|
|
1113
|
+
&session_name,
|
|
1114
|
+
&selected.run_workspace,
|
|
1115
|
+
&selected.team_key,
|
|
1116
|
+
generation,
|
|
1117
|
+
)
|
|
1118
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
1119
|
+
}
|
|
1139
1120
|
}
|
|
1140
1121
|
// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
|
|
1141
1122
|
// resume backing validation.
|
|
@@ -1344,8 +1325,16 @@ fn restart_with_selected_team_and_transport(
|
|
|
1344
1325
|
)?;
|
|
1345
1326
|
}
|
|
1346
1327
|
}
|
|
1328
|
+
// Only legacy survivors enter legacy readiness; contract results are
|
|
1329
|
+
// aggregated afterwards and must never enter backing scans/Codex fallback.
|
|
1330
|
+
let legacy_successful_agents = successful_agents.clone();
|
|
1331
|
+
#[cfg(unix)]
|
|
1332
|
+
append_native_restart_results(&mut successful_agents, &mut failed_agents, || {
|
|
1333
|
+
crate::contract_runtime::commands::restart_prepared(
|
|
1334
|
+
&selected.run_workspace, &selected.team_key, &mut state, native_restart,
|
|
1335
|
+
)
|
|
1336
|
+
});
|
|
1347
1337
|
if successful_agents.is_empty() && !failed_agents.is_empty() {
|
|
1348
|
-
// Postflight demoted every survivor — emit Failed before coordinator start.
|
|
1349
1338
|
let attach_commands = Vec::new();
|
|
1350
1339
|
let next_actions = restart_failure_next_actions(&failed_agents);
|
|
1351
1340
|
write_restart_completed_event(
|
|
@@ -1362,20 +1351,6 @@ fn restart_with_selected_team_and_transport(
|
|
|
1362
1351
|
attach_commands,
|
|
1363
1352
|
});
|
|
1364
1353
|
}
|
|
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
|
-
}
|
|
1379
1354
|
drop(lifecycle_lock);
|
|
1380
1355
|
let coordinator =
|
|
1381
1356
|
start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
|
|
@@ -1386,15 +1361,17 @@ fn restart_with_selected_team_and_transport(
|
|
|
1386
1361
|
);
|
|
1387
1362
|
let coordinator_started = coordinator.ok;
|
|
1388
1363
|
phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::ReadinessWait);
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1364
|
+
if !legacy_successful_agents.is_empty() {
|
|
1365
|
+
wait_restart_readiness_or_timeout(
|
|
1366
|
+
&selected.run_workspace,
|
|
1367
|
+
&state,
|
|
1368
|
+
&session_name,
|
|
1369
|
+
&legacy_successful_agents,
|
|
1370
|
+
transport,
|
|
1371
|
+
restart_readiness_deadline(readiness_deadline_ms),
|
|
1372
|
+
restart_readiness_poll_interval(),
|
|
1373
|
+
)?;
|
|
1374
|
+
}
|
|
1398
1375
|
let attach_commands =
|
|
1399
1376
|
crate::tmux_backend::attach_command_for_transport_session(transport, &session_name)
|
|
1400
1377
|
.or_else(|| {
|
|
@@ -1491,10 +1468,9 @@ fn repair_resume_sessions_from_event_log(
|
|
|
1491
1468
|
.and_then(|agents| agents.get(&agent_id))
|
|
1492
1469
|
.cloned()
|
|
1493
1470
|
.unwrap_or(serde_json::Value::Null);
|
|
1494
|
-
if previous
|
|
1495
|
-
.get("session_id")
|
|
1496
|
-
|
|
1497
|
-
.is_some_and(|session| !session.is_empty())
|
|
1471
|
+
if crate::state::projection::agent_stopped_for_restart(&previous)
|
|
1472
|
+
|| previous.get("session_id").and_then(serde_json::Value::as_str)
|
|
1473
|
+
.is_some_and(|session| !session.is_empty())
|
|
1498
1474
|
{
|
|
1499
1475
|
continue;
|
|
1500
1476
|
}
|
|
@@ -1724,12 +1700,65 @@ fn parse_duration_value_seconds_ms(value: &str) -> Option<u64> {
|
|
|
1724
1700
|
}
|
|
1725
1701
|
}
|
|
1726
1702
|
|
|
1703
|
+
/// Run the admitted native cohort before classifying any legacy failure. Keeping
|
|
1704
|
+
/// the execution inside this seam makes the mixed-failure ordering testable.
|
|
1705
|
+
#[cfg(unix)]
|
|
1706
|
+
fn append_native_restart_results(
|
|
1707
|
+
successful: &mut Vec<RestartedAgent>,
|
|
1708
|
+
failed: &mut Vec<RestartFailedAgent>,
|
|
1709
|
+
restart: impl FnOnce() -> Vec<crate::contract_runtime::commands::NativeRestartResult>,
|
|
1710
|
+
) {
|
|
1711
|
+
for result in restart() {
|
|
1712
|
+
let decision = RestartedAgent {
|
|
1713
|
+
agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
|
|
1714
|
+
decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
|
|
1715
|
+
};
|
|
1716
|
+
match result.outcome {
|
|
1717
|
+
Ok(()) => successful.push(decision),
|
|
1718
|
+
Err(error) => failed.push(restart_failed_agent(&decision, "native_resume", error)),
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1727
1723
|
fn restart_readiness_deadline(requested_ms: Option<u64>) -> std::time::Duration {
|
|
1728
1724
|
requested_ms
|
|
1729
1725
|
.map(std::time::Duration::from_millis)
|
|
1730
1726
|
.unwrap_or_else(|| env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_DEADLINE_MS"], 30_000))
|
|
1731
1727
|
}
|
|
1732
1728
|
|
|
1729
|
+
#[cfg(all(test, unix))]
|
|
1730
|
+
mod mixed_native_restart_tests {
|
|
1731
|
+
use super::*;
|
|
1732
|
+
use crate::contract_runtime::commands::NativeRestartResult;
|
|
1733
|
+
|
|
1734
|
+
#[test]
|
|
1735
|
+
fn failed_legacy_cohort_does_not_skip_native_resume_or_hide_its_result() {
|
|
1736
|
+
for legacy_failed in [false, true] {
|
|
1737
|
+
for native_failed in [false, true] {
|
|
1738
|
+
let mut successful = Vec::new();
|
|
1739
|
+
let legacy = RestartedAgent { agent_id: AgentId::new("legacy"),
|
|
1740
|
+
restart_mode: StartMode::Fresh, decision: ResumeDecision::FreshStart, session_id: None };
|
|
1741
|
+
let mut failed = if legacy_failed { vec![restart_failed_agent(&legacy, "spawn", "failed".into())] } else { vec![] };
|
|
1742
|
+
let mut attempts = 0;
|
|
1743
|
+
append_native_restart_results(&mut successful, &mut failed, || {
|
|
1744
|
+
attempts += 1;
|
|
1745
|
+
vec![NativeRestartResult { agent_id: "native".into(), session_id: Some("same-session".into()),
|
|
1746
|
+
outcome: if native_failed { Err("native failed".into()) } else { Ok(()) } }]
|
|
1747
|
+
});
|
|
1748
|
+
assert_eq!(attempts, 1, "no legacy survivor must not short-circuit native execution");
|
|
1749
|
+
assert_eq!(successful.len(), usize::from(!native_failed));
|
|
1750
|
+
assert_eq!(failed.len(), usize::from(legacy_failed) + usize::from(native_failed));
|
|
1751
|
+
if !native_failed {
|
|
1752
|
+
assert_eq!(successful[0].agent_id.as_str(), "native");
|
|
1753
|
+
assert_eq!(successful[0].session_id.as_ref().unwrap().as_str(), "same-session");
|
|
1754
|
+
} else {
|
|
1755
|
+
assert_eq!(failed.last().unwrap().phase, "native_resume");
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1733
1762
|
fn restart_readiness_poll_interval() -> std::time::Duration {
|
|
1734
1763
|
env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_POLL_MS"], 200)
|
|
1735
1764
|
}
|