@team-agent/installer 0.5.115 → 0.5.117
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/leader/lease.rs +1 -18
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- 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/messaging/delivery.rs +132 -27
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- package/crates/team-agent/src/messaging/types.rs +1 -0
- 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 +52 -1
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +594 -0
- package/crates/team-agent/src/transport.rs +19 -0
- 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
|
}
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
use std::collections::BTreeSet;
|
|
6
6
|
use std::path::{Path, PathBuf};
|
|
7
|
-
use std::sync::atomic::{AtomicU64, Ordering};
|
|
8
|
-
use std::time::{SystemTime, UNIX_EPOCH};
|
|
9
7
|
|
|
10
8
|
use serde_json::{json, Value};
|
|
11
9
|
use sha2::{Digest, Sha256};
|
|
@@ -1837,22 +1835,7 @@ fn canonical_workspace(workspace: &Path) -> PathBuf {
|
|
|
1837
1835
|
}
|
|
1838
1836
|
|
|
1839
1837
|
fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> String {
|
|
1840
|
-
|
|
1841
|
-
let nanos = SystemTime::now()
|
|
1842
|
-
.duration_since(UNIX_EPOCH)
|
|
1843
|
-
.map_or(0, |duration| duration.as_nanos());
|
|
1844
|
-
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
1845
|
-
let material = format!(
|
|
1846
|
-
"{}\0{endpoint}\0{}\0{}\0{nanos}\0{counter}",
|
|
1847
|
-
canonical_workspace(workspace).display(),
|
|
1848
|
-
pane.as_str(),
|
|
1849
|
-
std::process::id(),
|
|
1850
|
-
);
|
|
1851
|
-
let digest = Sha256::digest(material.as_bytes());
|
|
1852
|
-
digest[..16]
|
|
1853
|
-
.iter()
|
|
1854
|
-
.map(|byte| format!("{byte:02x}"))
|
|
1855
|
-
.collect()
|
|
1838
|
+
crate::tmux_backend::next_pane_binding_nonce(workspace, endpoint, pane)
|
|
1856
1839
|
}
|
|
1857
1840
|
|
|
1858
1841
|
fn pane_binding_nonce_for_claim(
|
|
@@ -36,6 +36,7 @@ pub(crate) struct PaneInputLockRequest<'a> {
|
|
|
36
36
|
|
|
37
37
|
pub(crate) struct PaneInputLockGuard {
|
|
38
38
|
file: Option<std::fs::File>,
|
|
39
|
+
guard_gate: Option<std::fs::File>,
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/// ---
|
|
@@ -54,18 +55,58 @@ pub(crate) fn acquire_or_proceed(request: PaneInputLockRequest<'_>) -> Option<Pa
|
|
|
54
55
|
// No workspace (some unit backends): nothing to lock against. Not an alarm.
|
|
55
56
|
return None;
|
|
56
57
|
};
|
|
57
|
-
acquire_file_lock(workspace, &request, timeout)
|
|
58
|
+
acquire_file_lock(workspace, &request, timeout, "proceed")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Every legacy writer holds shared admission through its whole operation,
|
|
62
|
+
/// even when its ordinary 200ms lock times out. Required guards hold exclusive
|
|
63
|
+
/// admission, so neither can enter while the other is already writing.
|
|
64
|
+
pub(crate) fn acquire_for_write(request: PaneInputLockRequest<'_>) -> Result<Option<PaneInputLockGuard>, ()> {
|
|
65
|
+
if request.workspace.is_none() { return Ok(None); }
|
|
66
|
+
let gate = acquire_guard_gate(&request, false)?;
|
|
67
|
+
let mut guard = acquire_or_proceed(request).unwrap_or(PaneInputLockGuard { file: None, guard_gate: None });
|
|
68
|
+
guard.guard_gate = Some(gate);
|
|
69
|
+
Ok(Some(guard))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn acquire_guard_gate(request: &PaneInputLockRequest<'_>, exclusive: bool) -> Result<std::fs::File, ()> {
|
|
73
|
+
let opened = (|| -> std::io::Result<std::fs::File> {
|
|
74
|
+
let workspace = request.workspace.ok_or_else(|| std::io::Error::other("workspace missing"))?;
|
|
75
|
+
let path = pane_input_lock_path(workspace, request.target_key).with_extension("guard");
|
|
76
|
+
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
|
|
77
|
+
let file = std::fs::OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)?;
|
|
78
|
+
let result = if exclusive { file.try_lock() } else { file.try_lock_shared() };
|
|
79
|
+
result.map_err(|error| match error {
|
|
80
|
+
std::fs::TryLockError::WouldBlock => std::io::Error::from(std::io::ErrorKind::WouldBlock),
|
|
81
|
+
std::fs::TryLockError::Error(error) => error,
|
|
82
|
+
})?;
|
|
83
|
+
Ok(file)
|
|
84
|
+
})();
|
|
85
|
+
opened.map_err(|_| emit_timeout_alarm(request, PANE_INPUT_LOCK_TIMEOUT, Duration::ZERO, "guard_gate", "refuse"))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Guarded native input must never treat a missing/contended lock as permission.
|
|
89
|
+
pub(crate) fn acquire_required(request: PaneInputLockRequest<'_>) -> Option<PaneInputLockGuard> {
|
|
90
|
+
let Some(workspace) = request.workspace else {
|
|
91
|
+
emit_timeout_alarm(&request, PANE_INPUT_LOCK_TIMEOUT, Duration::ZERO, "workspace_missing", "refuse");
|
|
92
|
+
return None;
|
|
93
|
+
};
|
|
94
|
+
let gate = acquire_guard_gate(&request, true).ok()?;
|
|
95
|
+
let mut guard = acquire_file_lock(workspace, &request, PANE_INPUT_LOCK_TIMEOUT, "refuse")?;
|
|
96
|
+
guard.guard_gate = Some(gate);
|
|
97
|
+
Some(guard)
|
|
58
98
|
}
|
|
59
99
|
|
|
60
100
|
fn acquire_file_lock(
|
|
61
101
|
workspace: &Path,
|
|
62
102
|
request: &PaneInputLockRequest<'_>,
|
|
63
103
|
timeout: Duration,
|
|
104
|
+
disposition: &str,
|
|
64
105
|
) -> Option<PaneInputLockGuard> {
|
|
65
106
|
let path = pane_input_lock_path(workspace, request.target_key);
|
|
66
107
|
if let Some(parent) = path.parent() {
|
|
67
108
|
if std::fs::create_dir_all(parent).is_err() {
|
|
68
|
-
emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_dir");
|
|
109
|
+
emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_dir", disposition);
|
|
69
110
|
return None;
|
|
70
111
|
}
|
|
71
112
|
}
|
|
@@ -78,7 +119,7 @@ fn acquire_file_lock(
|
|
|
78
119
|
{
|
|
79
120
|
Ok(file) => file,
|
|
80
121
|
Err(_) => {
|
|
81
|
-
emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_open");
|
|
122
|
+
emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_open", disposition);
|
|
82
123
|
return None;
|
|
83
124
|
}
|
|
84
125
|
};
|
|
@@ -86,17 +127,17 @@ fn acquire_file_lock(
|
|
|
86
127
|
loop {
|
|
87
128
|
match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
|
|
88
129
|
Ok(true) => {
|
|
89
|
-
return Some(PaneInputLockGuard { file: Some(file) });
|
|
130
|
+
return Some(PaneInputLockGuard { file: Some(file), guard_gate: None });
|
|
90
131
|
}
|
|
91
132
|
Ok(false) => {}
|
|
92
133
|
Err(_) => {
|
|
93
|
-
emit_timeout_alarm(request, timeout, started.elapsed(), "lock_io");
|
|
134
|
+
emit_timeout_alarm(request, timeout, started.elapsed(), "lock_io", disposition);
|
|
94
135
|
return None;
|
|
95
136
|
}
|
|
96
137
|
}
|
|
97
138
|
let elapsed = started.elapsed();
|
|
98
139
|
if elapsed >= timeout {
|
|
99
|
-
emit_timeout_alarm(request, timeout, elapsed, "timeout");
|
|
140
|
+
emit_timeout_alarm(request, timeout, elapsed, "timeout", disposition);
|
|
100
141
|
return None;
|
|
101
142
|
}
|
|
102
143
|
std::thread::sleep(std::cmp::min(
|
|
@@ -127,10 +168,11 @@ fn emit_timeout_alarm(
|
|
|
127
168
|
timeout: Duration,
|
|
128
169
|
waited: Duration,
|
|
129
170
|
reason: &str,
|
|
171
|
+
disposition: &str,
|
|
130
172
|
) {
|
|
131
173
|
// A5 watches this exact token on stderr. Commenting this line must turn A5 red.
|
|
132
174
|
eprintln!(
|
|
133
|
-
"{PANE_INPUT_LOCK_TIMEOUT_EVENT} target={} operation={} waited_ms={} timeout_ms={} reason={reason}
|
|
175
|
+
"{PANE_INPUT_LOCK_TIMEOUT_EVENT} target={} operation={} waited_ms={} timeout_ms={} reason={reason} disposition={disposition}",
|
|
134
176
|
request.target_key,
|
|
135
177
|
request.operation,
|
|
136
178
|
waited.as_millis(),
|
|
@@ -146,7 +188,7 @@ fn emit_timeout_alarm(
|
|
|
146
188
|
"waited_ms": waited.as_millis() as u64,
|
|
147
189
|
"timeout_ms": timeout.as_millis() as u64,
|
|
148
190
|
"reason": reason,
|
|
149
|
-
"disposition":
|
|
191
|
+
"disposition": disposition,
|
|
150
192
|
}),
|
|
151
193
|
);
|
|
152
194
|
}
|
|
@@ -157,5 +199,41 @@ impl Drop for PaneInputLockGuard {
|
|
|
157
199
|
if let Some(file) = self.file.take() {
|
|
158
200
|
let _ = crate::platform::file_lock::unlock(&file);
|
|
159
201
|
}
|
|
202
|
+
if let Some(gate) = self.guard_gate.take() { let _ = gate.unlock(); }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[cfg(test)]
|
|
207
|
+
mod required_tests {
|
|
208
|
+
#![allow(clippy::unwrap_used)]
|
|
209
|
+
use super::*;
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn required_pane_input_lock_missing_workspace_refuses() {
|
|
213
|
+
assert!(acquire_required(PaneInputLockRequest { workspace: None, target_key: "%1", operation: "fixture" }).is_none());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[test]
|
|
217
|
+
fn required_pane_input_lock_contention_refuses_and_drop_releases_same_key() {
|
|
218
|
+
let workspace = std::env::temp_dir().join(format!("ta-required-pane-lock-{}", std::process::id()));
|
|
219
|
+
std::fs::create_dir(&workspace).unwrap();
|
|
220
|
+
let request = || PaneInputLockRequest { workspace: Some(workspace.as_path()), target_key: "%1", operation: "fixture" };
|
|
221
|
+
let held = acquire_required(request()).unwrap();
|
|
222
|
+
assert!(acquire_required(request()).is_none());
|
|
223
|
+
let events = std::fs::read_to_string(workspace.join(".team/logs/events.jsonl")).unwrap();
|
|
224
|
+
assert!(events.lines().filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
|
225
|
+
.any(|row| row.to_string().contains("\"disposition\":\"refuse\"")));
|
|
226
|
+
drop(held);
|
|
227
|
+
drop(acquire_required(request()).unwrap());
|
|
228
|
+
// Preserve the old timeout/proceed policy, but hold reader admission
|
|
229
|
+
// through that unheld-main-lock writer so no required guard crosses it.
|
|
230
|
+
let first_writer = acquire_for_write(request()).unwrap().unwrap();
|
|
231
|
+
let timed_out_writer = acquire_for_write(request()).unwrap().unwrap();
|
|
232
|
+
assert!(timed_out_writer.file.is_none());
|
|
233
|
+
drop(first_writer);
|
|
234
|
+
assert!(acquire_required(request()).is_none());
|
|
235
|
+
drop(timed_out_writer);
|
|
236
|
+
drop(acquire_required(request()).unwrap());
|
|
237
|
+
std::fs::remove_dir_all(workspace).unwrap();
|
|
160
238
|
}
|
|
161
239
|
}
|
|
@@ -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);
|