@team-agent/installer 0.5.120 → 0.5.122
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/intent/runtime.rs +46 -10
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +114 -0
- package/crates/team-agent/src/cli/mod.rs +4 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +27 -8
- package/crates/team-agent/src/db/message_store.rs +65 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
- package/crates/team-agent/src/lifecycle/launch.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +10 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +16 -3
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +60 -17
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +162 -8
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +55 -6
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +495 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +272 -0
- package/crates/team-agent/src/mcp_server/wire.rs +96 -11
- package/crates/team-agent/src/messaging/delivery.rs +62 -0
- package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +12 -1
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +89 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +45 -14
- package/crates/team-agent/src/provider/adapter.rs +7 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -442,6 +442,37 @@ fn stop_args(plan: &Plan, id: &str) -> StopAgentArgs {
|
|
|
442
442
|
json: true,
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
|
+
fn check_incarnation_cwd(
|
|
446
|
+
selected: &SelectedTeam,
|
|
447
|
+
id: &str,
|
|
448
|
+
row: &Value,
|
|
449
|
+
provider: crate::provider::Provider,
|
|
450
|
+
) -> Result<(), CliError> {
|
|
451
|
+
if let Some(cwd) = row
|
|
452
|
+
.get("spawn_cwd")
|
|
453
|
+
.and_then(Value::as_str)
|
|
454
|
+
.filter(|cwd| !cwd.is_empty())
|
|
455
|
+
{
|
|
456
|
+
let declared_cwd = Path::new(cwd);
|
|
457
|
+
let cwd = checked(declared_cwd.canonicalize())?;
|
|
458
|
+
let inside = cwd.starts_with(&selected.run_workspace);
|
|
459
|
+
#[cfg(unix)]
|
|
460
|
+
let owned_cursor_primary = !inside
|
|
461
|
+
&& provider == crate::provider::Provider::CursorAgent
|
|
462
|
+
&& checked(crate::lifecycle::launch::cursor_mcp_primary_matches_scope(
|
|
463
|
+
&selected.run_workspace,
|
|
464
|
+
&selected.team_key,
|
|
465
|
+
id,
|
|
466
|
+
declared_cwd,
|
|
467
|
+
))?;
|
|
468
|
+
#[cfg(not(unix))]
|
|
469
|
+
let owned_cursor_primary = false;
|
|
470
|
+
if !cwd.is_dir() || !(inside || owned_cursor_primary) {
|
|
471
|
+
return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
Ok(())
|
|
475
|
+
}
|
|
445
476
|
fn preflight_start(
|
|
446
477
|
selected: &SelectedTeam,
|
|
447
478
|
id: &str,
|
|
@@ -515,16 +546,7 @@ fn preflight_start(
|
|
|
515
546
|
{
|
|
516
547
|
return Err(refusal("resume metadata lacks the scoped spawn cwd"));
|
|
517
548
|
}
|
|
518
|
-
|
|
519
|
-
.get("spawn_cwd")
|
|
520
|
-
.and_then(Value::as_str)
|
|
521
|
-
.filter(|cwd| !cwd.is_empty())
|
|
522
|
-
{
|
|
523
|
-
let cwd = checked(Path::new(cwd).canonicalize())?;
|
|
524
|
-
if !cwd.is_dir() || !cwd.starts_with(&selected.run_workspace) {
|
|
525
|
-
return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
|
|
526
|
-
}
|
|
527
|
-
}
|
|
549
|
+
check_incarnation_cwd(selected, id, row, provider)?;
|
|
528
550
|
if row
|
|
529
551
|
.get("task_prompt_delivered")
|
|
530
552
|
.is_some_and(|value| !value.is_null() && !value.is_boolean())
|
|
@@ -1134,6 +1156,20 @@ pub(super) fn recheck(plan: &Plan, step: &Step, after_stop: bool) -> Result<(),
|
|
|
1134
1156
|
}
|
|
1135
1157
|
}
|
|
1136
1158
|
}
|
|
1159
|
+
#[cfg(unix)]
|
|
1160
|
+
if !crate::contract_runtime::framework::is_contract(row)
|
|
1161
|
+
&& crate::provider::wire::parse_provider(provider(row))
|
|
1162
|
+
== Some(crate::provider::Provider::CursorAgent)
|
|
1163
|
+
&& (matches!(step, Step::StartAgent(_))
|
|
1164
|
+
|| (plan.intent.verb == Verb::Restart && matches!(step, Step::StopAgent(_)))
|
|
1165
|
+
|| (matches!(step, Step::RestartTeam(_))
|
|
1166
|
+
&& row.get("status").and_then(Value::as_str) == Some("running")
|
|
1167
|
+
&& !crate::state::projection::agent_explicit_stop(row)))
|
|
1168
|
+
{
|
|
1169
|
+
// Reopen the same ownership proof at the existing execution recheck,
|
|
1170
|
+
// before effects; a planning-time receipt is not a filesystem lease.
|
|
1171
|
+
check_incarnation_cwd(&selected, id, row, crate::provider::Provider::CursorAgent)?;
|
|
1172
|
+
}
|
|
1137
1173
|
}
|
|
1138
1174
|
Ok(())
|
|
1139
1175
|
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
use super::super::tests::Sandbox;
|
|
3
3
|
use super::*;
|
|
4
4
|
|
|
5
|
+
#[cfg(unix)]
|
|
6
|
+
#[path = "../../../tests/support/hermetic.rs"]
|
|
7
|
+
mod hermetic_guard;
|
|
8
|
+
|
|
5
9
|
fn fixture(ids: &[&str]) -> (Sandbox, Intent) {
|
|
6
10
|
let root = Sandbox::new();
|
|
7
11
|
std::fs::create_dir(root.path().join("agents")).unwrap();
|
|
@@ -311,6 +315,116 @@ fn preserve_rejects_known_missing_backing_before_stop_auto_uses_existing_decisio
|
|
|
311
315
|
}
|
|
312
316
|
}
|
|
313
317
|
|
|
318
|
+
#[cfg(unix)]
|
|
319
|
+
fn cursor_scope_fixture() -> (
|
|
320
|
+
hermetic_guard::HermeticTestEnv,
|
|
321
|
+
Sandbox,
|
|
322
|
+
SelectedTeam,
|
|
323
|
+
PathBuf,
|
|
324
|
+
Value,
|
|
325
|
+
) {
|
|
326
|
+
let env = hermetic_guard::HermeticTestEnv::enter("cursor-intent-scope");
|
|
327
|
+
let (root, _) = fixture(&["one"]);
|
|
328
|
+
install(root.path(), &[("one", "stopped", true)]);
|
|
329
|
+
let selected =
|
|
330
|
+
resolve_active_team_readonly(root.path(), Some("fixture"), SelectorMode::RuntimeOnly)
|
|
331
|
+
.unwrap();
|
|
332
|
+
let config = crate::provider::McpConfig {
|
|
333
|
+
raw: json!({"team_orchestrator": {
|
|
334
|
+
"command": std::env::current_exe().unwrap(),
|
|
335
|
+
"args": ["mcp-server", "--workspace", root.path()],
|
|
336
|
+
"env": {"TEAM_AGENT_WORKSPACE": root.path(), "TEAM_AGENT_ID": "one", "TEAM_AGENT_OWNER_TEAM_ID": "fixture", "TEAM_AGENT_AUTH_MODE": "subscription"}
|
|
337
|
+
}}),
|
|
338
|
+
};
|
|
339
|
+
let primary = crate::lifecycle::launch::prepare_cursor_seat_mcp(root.path(), "one", &config)
|
|
340
|
+
.unwrap()
|
|
341
|
+
.unwrap();
|
|
342
|
+
let sid = "af3ce55e-2ee9-4bc5-8dae-b628e7447286";
|
|
343
|
+
let archive = env
|
|
344
|
+
.home()
|
|
345
|
+
.join(".cursor/chats")
|
|
346
|
+
.join(format!(
|
|
347
|
+
"{:x}",
|
|
348
|
+
md5::compute(primary.to_string_lossy().as_bytes())
|
|
349
|
+
))
|
|
350
|
+
.join(sid);
|
|
351
|
+
std::fs::create_dir_all(&archive).unwrap();
|
|
352
|
+
std::fs::write(archive.join("meta.json"), b"{}").unwrap();
|
|
353
|
+
let row = json!({"provider":"cursor_agent","status":"stopped","explicit_stop":true,"session_id":sid,"rollout_path":archive,"spawn_cwd":primary});
|
|
354
|
+
(env, root, selected, primary, row)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#[test]
|
|
358
|
+
#[cfg(unix)]
|
|
359
|
+
#[serial_test::serial(env)]
|
|
360
|
+
fn issue342_cursor_owned_primary_scope_is_readonly_and_exact() {
|
|
361
|
+
let (_env, root, selected, primary, row) = cursor_scope_fixture();
|
|
362
|
+
let state = crate::state::persist::runtime_state_path(root.path());
|
|
363
|
+
let record = root
|
|
364
|
+
.path()
|
|
365
|
+
.join(".team/runtime/provider-config/one/cursor-project.json");
|
|
366
|
+
let paths = [state, record, primary.join(".cursor/mcp.json")];
|
|
367
|
+
let before: Vec<_> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
|
|
368
|
+
assert!(!primary.starts_with(root.path()));
|
|
369
|
+
for policy in [SessionPolicy::Preserve, SessionPolicy::Auto] {
|
|
370
|
+
assert!(preflight_start(&selected, "one", &row, policy).is_ok());
|
|
371
|
+
}
|
|
372
|
+
let after: Vec<_> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
|
|
373
|
+
assert_eq!(
|
|
374
|
+
before, after,
|
|
375
|
+
"scope preflight must not write state, record or MCP config"
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
let mut foreign_provider = row.clone();
|
|
379
|
+
foreign_provider["provider"] = json!("pi");
|
|
380
|
+
assert!(preflight_start(&selected, "one", &foreign_provider, SessionPolicy::Auto).is_err());
|
|
381
|
+
let mut other_team = selected.clone();
|
|
382
|
+
other_team.team_key = "foreign-team".into();
|
|
383
|
+
assert!(preflight_start(&other_team, "one", &row, SessionPolicy::Preserve).is_err());
|
|
384
|
+
assert!(preflight_start(&selected, "two", &row, SessionPolicy::Preserve).is_err());
|
|
385
|
+
let outside = Sandbox::new();
|
|
386
|
+
let mut other_workspace = selected.clone();
|
|
387
|
+
other_workspace.run_workspace = outside.path().to_owned();
|
|
388
|
+
assert!(preflight_start(&other_workspace, "one", &row, SessionPolicy::Preserve).is_err());
|
|
389
|
+
assert!(!outside.path().join(".team").exists());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#[test]
|
|
393
|
+
#[cfg(unix)]
|
|
394
|
+
#[serial_test::serial(env)]
|
|
395
|
+
fn issue342_cursor_scope_refuses_unrecorded_alias_and_replaced_incarnation() {
|
|
396
|
+
use std::os::unix::fs::{symlink, DirBuilderExt};
|
|
397
|
+
let (_env, root, selected, primary, row) = cursor_scope_fixture();
|
|
398
|
+
let record = root
|
|
399
|
+
.path()
|
|
400
|
+
.join(".team/runtime/provider-config/one/cursor-project.json");
|
|
401
|
+
let bytes = std::fs::read(&record).unwrap();
|
|
402
|
+
let retained = record.with_extension("retained");
|
|
403
|
+
std::fs::rename(&record, &retained).unwrap();
|
|
404
|
+
assert!(preflight_start(&selected, "one", &row, SessionPolicy::Preserve).is_err());
|
|
405
|
+
assert!(
|
|
406
|
+
!record.exists(),
|
|
407
|
+
"a same-named external directory is not authority to create a record"
|
|
408
|
+
);
|
|
409
|
+
std::fs::rename(retained, &record).unwrap();
|
|
410
|
+
let mut forged = row.clone();
|
|
411
|
+
let arbitrary = Sandbox::new();
|
|
412
|
+
forged["spawn_cwd"] = json!(arbitrary.path());
|
|
413
|
+
assert!(preflight_start(&selected, "one", &forged, SessionPolicy::Auto).is_err());
|
|
414
|
+
let alias = root.path().join("primary-alias");
|
|
415
|
+
symlink(&primary, &alias).unwrap();
|
|
416
|
+
forged["spawn_cwd"] = json!(alias);
|
|
417
|
+
assert!(preflight_start(&selected, "one", &forged, SessionPolicy::Preserve).is_err());
|
|
418
|
+
let old = primary.with_extension("retained");
|
|
419
|
+
std::fs::rename(&primary, old).unwrap();
|
|
420
|
+
std::fs::DirBuilder::new()
|
|
421
|
+
.mode(0o700)
|
|
422
|
+
.create(&primary)
|
|
423
|
+
.unwrap();
|
|
424
|
+
assert!(preflight_start(&selected, "one", &row, SessionPolicy::Auto).is_err());
|
|
425
|
+
assert_eq!(std::fs::read(record).unwrap(), bytes);
|
|
426
|
+
}
|
|
427
|
+
|
|
314
428
|
#[test]
|
|
315
429
|
#[serial_test::serial(env)]
|
|
316
430
|
fn changed_role_and_socket_refuse_in_the_real_readonly_recheck() {
|
|
@@ -1724,7 +1724,10 @@ pub mod lifecycle_port {
|
|
|
1724
1724
|
let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
|
|
1725
1725
|
let coordinator_pid_before_stop = crate::coordinator::coordinator_health(&wp).pid;
|
|
1726
1726
|
coordinator_pid_for_report = coordinator_pid_before_stop.map(|pid| pid.get());
|
|
1727
|
-
|
|
1727
|
+
// Owned termination can use 5s graceful + 5s escalation waits.
|
|
1728
|
+
// Await its result within the existing shutdown deadline, not an
|
|
1729
|
+
// unrelated 900ms cap that abandons a still-finishing stop worker.
|
|
1730
|
+
match stop_coordinator_bounded(wp, deadline.remaining()) {
|
|
1728
1731
|
Some(Ok(report)) => Some(report),
|
|
1729
1732
|
Some(Err(error)) => {
|
|
1730
1733
|
kill_error.get_or_insert(error);
|
|
@@ -1254,6 +1254,62 @@ fn bounded_coordinator_stop_returns_grace_window_late_success() {
|
|
|
1254
1254
|
assert_eq!(report.status, crate::coordinator::StopOutcome::Stopped);
|
|
1255
1255
|
}
|
|
1256
1256
|
|
|
1257
|
+
#[test]
|
|
1258
|
+
fn bounded_coordinator_stop_retains_completion_beyond_old_1150ms_cap() {
|
|
1259
|
+
let ws = tmp_shutdown_workspace("coordinator-stop-after-old-cap");
|
|
1260
|
+
let cleanup = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
1261
|
+
let worker_cleanup = cleanup.clone();
|
|
1262
|
+
let report = crate::cli::lifecycle_port::stop_coordinator_bounded_with(
|
|
1263
|
+
crate::coordinator::WorkspacePath::new(ws),
|
|
1264
|
+
std::time::Duration::from_secs(20),
|
|
1265
|
+
move |_workspace| {
|
|
1266
|
+
std::thread::sleep(std::time::Duration::from_millis(1300));
|
|
1267
|
+
worker_cleanup.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
1268
|
+
Ok(crate::coordinator::StopReport {
|
|
1269
|
+
ok: true,
|
|
1270
|
+
status: crate::coordinator::StopOutcome::Stopped,
|
|
1271
|
+
pid: Some(crate::coordinator::Pid::new(12345)),
|
|
1272
|
+
})
|
|
1273
|
+
},
|
|
1274
|
+
)
|
|
1275
|
+
.expect("completion inside the shutdown budget must not be abandoned")
|
|
1276
|
+
.expect("owned stop completed successfully");
|
|
1277
|
+
|
|
1278
|
+
assert!(cleanup.load(std::sync::atomic::Ordering::SeqCst));
|
|
1279
|
+
assert!(report.ok);
|
|
1280
|
+
assert_eq!(report.status, crate::coordinator::StopOutcome::Stopped);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
#[test]
|
|
1284
|
+
fn bounded_coordinator_stop_does_not_claim_completion_after_budget_exhaustion() {
|
|
1285
|
+
let ws = tmp_shutdown_workspace("coordinator-stop-real-timeout");
|
|
1286
|
+
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
|
1287
|
+
let (completed_tx, completed_rx) = std::sync::mpsc::channel();
|
|
1288
|
+
let report = crate::cli::lifecycle_port::stop_coordinator_bounded_with(
|
|
1289
|
+
crate::coordinator::WorkspacePath::new(ws),
|
|
1290
|
+
std::time::Duration::from_millis(1),
|
|
1291
|
+
move |_workspace| {
|
|
1292
|
+
release_rx.recv().unwrap();
|
|
1293
|
+
completed_tx.send(()).unwrap();
|
|
1294
|
+
Ok(crate::coordinator::StopReport {
|
|
1295
|
+
ok: true,
|
|
1296
|
+
status: crate::coordinator::StopOutcome::Stopped,
|
|
1297
|
+
pid: Some(crate::coordinator::Pid::new(12345)),
|
|
1298
|
+
})
|
|
1299
|
+
},
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1302
|
+
assert!(report.is_none(), "an unfinished stop must remain unknown");
|
|
1303
|
+
assert!(matches!(
|
|
1304
|
+
completed_rx.try_recv(),
|
|
1305
|
+
Err(std::sync::mpsc::TryRecvError::Empty)
|
|
1306
|
+
));
|
|
1307
|
+
release_tx.send(()).unwrap();
|
|
1308
|
+
completed_rx
|
|
1309
|
+
.recv_timeout(std::time::Duration::from_secs(5))
|
|
1310
|
+
.expect("release the controlled worker without leaving a blocked thread");
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1257
1313
|
#[test]
|
|
1258
1314
|
fn shutdown_outcome_late_or_postcheck_gone_is_ok_with_lsof_diagnostic() {
|
|
1259
1315
|
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
@@ -15,6 +15,7 @@ use super::*;
|
|
|
15
15
|
/// agent. `has_session`→true (gate passes); `inject`→Ok (delivery may run); the rest are Ok defaults.
|
|
16
16
|
struct CapturingTransport {
|
|
17
17
|
scrollback: String,
|
|
18
|
+
targets: Vec<PaneInfo>,
|
|
18
19
|
}
|
|
19
20
|
impl Transport for CapturingTransport {
|
|
20
21
|
fn kind(&self) -> BackendKind {
|
|
@@ -73,7 +74,7 @@ impl Transport for CapturingTransport {
|
|
|
73
74
|
Ok(PaneLiveness::Live)
|
|
74
75
|
}
|
|
75
76
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
76
|
-
Ok(
|
|
77
|
+
Ok(self.targets.clone())
|
|
77
78
|
}
|
|
78
79
|
fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
|
|
79
80
|
Ok(true)
|
|
@@ -106,6 +107,14 @@ impl Transport for CapturingTransport {
|
|
|
106
107
|
fn seeded_health_coord(
|
|
107
108
|
agents: serde_json::Value,
|
|
108
109
|
scrollback: &str,
|
|
110
|
+
) -> (Coordinator, std::path::PathBuf) {
|
|
111
|
+
seeded_health_coord_with_readiness(agents, scrollback, false)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn seeded_health_coord_with_readiness(
|
|
115
|
+
agents: serde_json::Value,
|
|
116
|
+
scrollback: &str,
|
|
117
|
+
ready: bool,
|
|
109
118
|
) -> (Coordinator, std::path::PathBuf) {
|
|
110
119
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
111
120
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
@@ -115,11 +124,18 @@ fn seeded_health_coord(
|
|
|
115
124
|
N.fetch_add(1, Ordering::Relaxed)
|
|
116
125
|
));
|
|
117
126
|
std::fs::create_dir_all(&dir).unwrap();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
let mut state = serde_json::json!({ "session_name": "team-health", "agents": agents });
|
|
128
|
+
#[cfg(unix)]
|
|
129
|
+
let targets = if ready {
|
|
130
|
+
vec![crate::mcp_server::readiness::test_ready_worker(
|
|
131
|
+
&dir, &mut state, "current", "w1", "%1",
|
|
132
|
+
)]
|
|
133
|
+
} else {
|
|
134
|
+
Vec::new()
|
|
135
|
+
};
|
|
136
|
+
#[cfg(not(unix))]
|
|
137
|
+
let targets = { let _ = ready; Vec::new() };
|
|
138
|
+
crate::state::persist::save_runtime_state(&dir, &state).unwrap();
|
|
123
139
|
let ws = WorkspacePath::new(dir.clone());
|
|
124
140
|
let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
|
|
125
141
|
let coord = Coordinator::for_test(
|
|
@@ -127,6 +143,7 @@ fn seeded_health_coord(
|
|
|
127
143
|
reg,
|
|
128
144
|
Box::new(CapturingTransport {
|
|
129
145
|
scrollback: scrollback.to_string(),
|
|
146
|
+
targets,
|
|
130
147
|
}),
|
|
131
148
|
None,
|
|
132
149
|
None,
|
|
@@ -260,10 +277,12 @@ fn spine2_capture_missing_captures_session_id_for_missing_agent() {
|
|
|
260
277
|
// activity/agent_health WORKING remains diagnostic turn state.
|
|
261
278
|
#[test]
|
|
262
279
|
fn spine2_sync_health_working_status_delivers_same_tick() {
|
|
263
|
-
let (coord, dir) =
|
|
280
|
+
let (coord, dir) = seeded_health_coord_with_readiness(
|
|
281
|
+
one_agent("codex"), "Working (5s · esc to interrupt)", true,
|
|
282
|
+
);
|
|
264
283
|
let store = MessageStore::open(&dir).unwrap();
|
|
265
284
|
let mid = store
|
|
266
|
-
.create_message(Some("t"), "leader", "w1", "hi", None, true,
|
|
285
|
+
.create_message(Some("t"), "leader", "w1", "hi", None, true, Some("current"))
|
|
267
286
|
.unwrap();
|
|
268
287
|
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
269
288
|
conn.execute(
|
|
@@ -585,6 +585,30 @@ impl MessageStore {
|
|
|
585
585
|
Ok(rows == 1)
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
/// Release only an owned claim that has performed no input. Readiness
|
|
589
|
+
/// waiting must remain retryable without consuming the input-attempt budget.
|
|
590
|
+
/// A changed attempt, status, or error means this caller no longer owns it.
|
|
591
|
+
pub(crate) fn release_delivery_before_input(
|
|
592
|
+
&self,
|
|
593
|
+
message_id: &str,
|
|
594
|
+
attempt: u32,
|
|
595
|
+
reason: &str,
|
|
596
|
+
) -> Result<bool, MessageStoreError> {
|
|
597
|
+
if attempt == 0 || reason.is_empty() {
|
|
598
|
+
return Ok(false);
|
|
599
|
+
}
|
|
600
|
+
let conn = crate::db::schema::open_db(&self.path)?;
|
|
601
|
+
let rows = conn.execute(
|
|
602
|
+
"update messages
|
|
603
|
+
set delivery_attempts = delivery_attempts - 1,
|
|
604
|
+
error = ?3, updated_at = ?4
|
|
605
|
+
where message_id = ?1 and status = 'target_resolved'
|
|
606
|
+
and error is null and delivery_attempts = ?2",
|
|
607
|
+
params![message_id, attempt, reason, now_ts()],
|
|
608
|
+
)?;
|
|
609
|
+
Ok(rows == 1)
|
|
610
|
+
}
|
|
611
|
+
|
|
588
612
|
/// Read inbox rows through [`Self::inbox`]; recovery transitions below are
|
|
589
613
|
/// separate repository owners and are not claim eligibility.
|
|
590
614
|
pub fn requeue_blocked_leader_messages(
|
|
@@ -1168,6 +1192,47 @@ mod tests {
|
|
|
1168
1192
|
assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 1);
|
|
1169
1193
|
}
|
|
1170
1194
|
|
|
1195
|
+
#[test]
|
|
1196
|
+
fn release_delivery_before_input_preserves_budget_and_single_winner() {
|
|
1197
|
+
let s = store();
|
|
1198
|
+
let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
|
|
1199
|
+
assert!(s.claim_for_delivery(&mid).unwrap());
|
|
1200
|
+
assert!(!s.claim_for_delivery(&mid).unwrap());
|
|
1201
|
+
assert!(s.release_delivery_before_input(&mid, 1, "mcp_handshake_missing").unwrap());
|
|
1202
|
+
assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 0);
|
|
1203
|
+
assert_eq!(col_str(&read(&s), &mid, "error").as_deref(), Some("mcp_handshake_missing"));
|
|
1204
|
+
assert!(!s.release_delivery_before_input(&mid, 1, "late").unwrap());
|
|
1205
|
+
assert!(s.claim_for_delivery(&mid).unwrap());
|
|
1206
|
+
assert!(!s.claim_for_delivery(&mid).unwrap());
|
|
1207
|
+
assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 1);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
#[test]
|
|
1211
|
+
fn release_delivery_before_input_cannot_overwrite_changed_claims() {
|
|
1212
|
+
let s = store();
|
|
1213
|
+
let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
|
|
1214
|
+
assert!(s.claim_for_delivery(&mid).unwrap());
|
|
1215
|
+
assert!(!s.release_delivery_before_input(&mid, 0, "missing").unwrap());
|
|
1216
|
+
assert!(!s.release_delivery_before_input(&mid, 2, "stale").unwrap());
|
|
1217
|
+
s.mark(&mid, "target_resolved", Some("other_owner")).unwrap();
|
|
1218
|
+
assert!(!s.release_delivery_before_input(&mid, 1, "stale").unwrap());
|
|
1219
|
+
assert_eq!(col_str(&read(&s), &mid, "error").as_deref(), Some("other_owner"));
|
|
1220
|
+
assert!(s.claim_for_delivery(&mid).unwrap());
|
|
1221
|
+
assert!(!s.release_delivery_before_input(&mid, 1, "old_claim").unwrap());
|
|
1222
|
+
assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 2);
|
|
1223
|
+
assert_eq!(col_str(&read(&s), &mid, "error"), None);
|
|
1224
|
+
for status in ["cancelled", "delivered", "submitted_unverified"] {
|
|
1225
|
+
// Each terminal/effect state needs its own row: mark correctly
|
|
1226
|
+
// refuses to downgrade a previously delivered row.
|
|
1227
|
+
let changed = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
|
|
1228
|
+
assert!(s.claim_for_delivery(&changed).unwrap());
|
|
1229
|
+
s.mark(&changed, status, None).unwrap();
|
|
1230
|
+
assert!(!s.release_delivery_before_input(&changed, 1, "stale").unwrap());
|
|
1231
|
+
assert_eq!(status_of(&read(&s), &changed), status);
|
|
1232
|
+
assert_eq!(col_i64(&read(&s), &changed, "delivery_attempts"), 1);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1171
1236
|
#[test]
|
|
1172
1237
|
fn claim_for_delivery_never_claims_stored_only_presentation() {
|
|
1173
1238
|
let s = store();
|
|
@@ -129,6 +129,14 @@ pub(super) fn running_agent_state(
|
|
|
129
129
|
if let Some(started_agent) = started_agent {
|
|
130
130
|
persist_started_agent_plan_state(&mut state, started_agent);
|
|
131
131
|
}
|
|
132
|
+
// Cursor chdirs to its native --workspace primary. Session archives are
|
|
133
|
+
// keyed by this directory, not the real project supplied via --add-dir.
|
|
134
|
+
let cursor_cwd = if provider == Provider::CursorAgent && cursor_mcp_isolation_enabled() {
|
|
135
|
+
Some(cursor_mcp_project_dir(workspace, id)?)
|
|
136
|
+
} else {
|
|
137
|
+
None
|
|
138
|
+
};
|
|
139
|
+
let spawn_cwd = cursor_cwd.as_deref().unwrap_or(spawn_cwd);
|
|
132
140
|
state.insert(
|
|
133
141
|
"spawn_cwd".to_string(),
|
|
134
142
|
serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
|
|
@@ -33,6 +33,7 @@ use crate::provider::Provider;
|
|
|
33
33
|
|
|
34
34
|
use super::cursor_mcp_iso::{
|
|
35
35
|
cursor_mcp_isolation_enabled, cursor_mcp_project_dir, materialize_cursor_mcp_project,
|
|
36
|
+
validate_cursor_mcp_project_root,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
/// Keys that must appear in mcp.json env. Cursor strips parent env down to
|
|
@@ -280,8 +281,31 @@ pub fn prepare_cursor_seat_mcp(
|
|
|
280
281
|
mcp_config: &crate::provider::McpConfig,
|
|
281
282
|
) -> Result<Option<PathBuf>, LifecycleError> {
|
|
282
283
|
if cursor_mcp_isolation_enabled() {
|
|
283
|
-
let
|
|
284
|
+
let server = mcp_config
|
|
285
|
+
.raw
|
|
286
|
+
.get("team_orchestrator")
|
|
287
|
+
.or_else(|| mcp_config.raw.get("team-agent"));
|
|
288
|
+
let env = server.and_then(|s| s.get("env"));
|
|
289
|
+
let field = |key| {
|
|
290
|
+
env.and_then(|v| v.get(key))
|
|
291
|
+
.and_then(serde_json::Value::as_str)
|
|
292
|
+
};
|
|
293
|
+
let physical = physical_workspace_path(workspace);
|
|
294
|
+
if field("TEAM_AGENT_ID") != Some(agent_id)
|
|
295
|
+
|| field("TEAM_AGENT_WORKSPACE") != physical.to_str()
|
|
296
|
+
{
|
|
297
|
+
return Err(LifecycleError::RequirementUnmet(
|
|
298
|
+
"cursor MCP resolved identity does not match the selected workspace/seat".into(),
|
|
299
|
+
));
|
|
300
|
+
}
|
|
301
|
+
let owner = field("TEAM_AGENT_OWNER_TEAM_ID")
|
|
302
|
+
.filter(|s| !s.trim().is_empty())
|
|
303
|
+
.ok_or_else(|| {
|
|
304
|
+
LifecycleError::RequirementUnmet("cursor MCP missing owner team".into())
|
|
305
|
+
})?;
|
|
306
|
+
let project = materialize_cursor_mcp_project(&physical, agent_id, owner)?;
|
|
284
307
|
apply_cursor_mcp_overlay(&project, mcp_config)?;
|
|
308
|
+
validate_cursor_project_mcp(&physical, &project)?;
|
|
285
309
|
Ok(Some(project))
|
|
286
310
|
} else {
|
|
287
311
|
apply_cursor_mcp_overlay(workspace, mcp_config)?;
|
|
@@ -305,14 +329,15 @@ pub fn enable_cursor_workspace_mcp_with_profile(
|
|
|
305
329
|
profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
|
|
306
330
|
) -> Result<(), LifecycleError> {
|
|
307
331
|
let physical = cursor_mcp_enable_working_dir(workspace, project_root);
|
|
332
|
+
if project_root.is_some() {
|
|
333
|
+
validate_cursor_project_mcp(&physical_workspace_path(workspace), &physical)?;
|
|
334
|
+
}
|
|
308
335
|
if skip_cursor_mcp_enable() {
|
|
309
336
|
return Ok(());
|
|
310
337
|
}
|
|
311
338
|
let argv = cursor_mcp_enable_argv();
|
|
312
339
|
let mut command = Command::new(&argv[0]);
|
|
313
|
-
command
|
|
314
|
-
.args(&argv[1..])
|
|
315
|
-
.current_dir(&physical);
|
|
340
|
+
command.args(&argv[1..]).current_dir(&physical);
|
|
316
341
|
apply_cursor_mcp_enable_profile_env(&mut command, profile_launch);
|
|
317
342
|
let output = command.output().map_err(|e| {
|
|
318
343
|
LifecycleError::RequirementUnmet(format!(
|
|
@@ -323,6 +348,11 @@ pub fn enable_cursor_workspace_mcp_with_profile(
|
|
|
323
348
|
))
|
|
324
349
|
})?;
|
|
325
350
|
if output.status.success() {
|
|
351
|
+
// Approval is not native tool readiness. Recheck our root/config after
|
|
352
|
+
// the external command; native loading/calls still need their own proof.
|
|
353
|
+
if project_root.is_some() {
|
|
354
|
+
validate_cursor_project_mcp(&physical_workspace_path(workspace), &physical)?;
|
|
355
|
+
}
|
|
326
356
|
return Ok(());
|
|
327
357
|
}
|
|
328
358
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
@@ -343,6 +373,83 @@ pub fn enable_cursor_workspace_mcp_with_profile(
|
|
|
343
373
|
)))
|
|
344
374
|
}
|
|
345
375
|
|
|
376
|
+
fn validate_cursor_project_mcp(workspace: &Path, project: &Path) -> Result<(), LifecycleError> {
|
|
377
|
+
use std::io::Read;
|
|
378
|
+
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
|
379
|
+
validate_cursor_mcp_project_root(project)?;
|
|
380
|
+
let refuse = || {
|
|
381
|
+
LifecycleError::RequirementUnmet(
|
|
382
|
+
"cursor MCP private config is missing, mismatched, or its server executable is unavailable; native tools are not ready".into(),
|
|
383
|
+
)
|
|
384
|
+
};
|
|
385
|
+
let file = std::fs::OpenOptions::new()
|
|
386
|
+
.read(true)
|
|
387
|
+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
388
|
+
.open(project.join(".cursor/mcp.json"))
|
|
389
|
+
.map_err(|_| refuse())?;
|
|
390
|
+
let metadata = file.metadata().map_err(|_| refuse())?;
|
|
391
|
+
if !metadata.is_file() || metadata.nlink() != 1 || metadata.uid() != unsafe { libc::geteuid() }
|
|
392
|
+
{
|
|
393
|
+
return Err(refuse());
|
|
394
|
+
}
|
|
395
|
+
let mut bytes = Vec::new();
|
|
396
|
+
file.take(65537)
|
|
397
|
+
.read_to_end(&mut bytes)
|
|
398
|
+
.map_err(|_| refuse())?;
|
|
399
|
+
if bytes.len() > 65536 {
|
|
400
|
+
return Err(refuse());
|
|
401
|
+
}
|
|
402
|
+
let config: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| refuse())?;
|
|
403
|
+
let server = config
|
|
404
|
+
.get("mcpServers")
|
|
405
|
+
.and_then(|v| v.get("team_orchestrator"))
|
|
406
|
+
.ok_or_else(refuse)?;
|
|
407
|
+
let env = server.get("env").ok_or_else(refuse)?;
|
|
408
|
+
for key in REQUIRED_IDENTITY_KEYS {
|
|
409
|
+
if env
|
|
410
|
+
.get(*key)
|
|
411
|
+
.and_then(serde_json::Value::as_str)
|
|
412
|
+
.is_none_or(|s| s.trim().is_empty() || s.contains('{'))
|
|
413
|
+
{
|
|
414
|
+
return Err(refuse());
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
let seat = env
|
|
418
|
+
.get("TEAM_AGENT_ID")
|
|
419
|
+
.and_then(serde_json::Value::as_str)
|
|
420
|
+
.ok_or_else(refuse)?;
|
|
421
|
+
let owner = env
|
|
422
|
+
.get("TEAM_AGENT_OWNER_TEAM_ID")
|
|
423
|
+
.and_then(serde_json::Value::as_str)
|
|
424
|
+
.ok_or_else(refuse)?;
|
|
425
|
+
if env
|
|
426
|
+
.get("TEAM_AGENT_WORKSPACE")
|
|
427
|
+
.and_then(serde_json::Value::as_str)
|
|
428
|
+
!= workspace.to_str()
|
|
429
|
+
|| cursor_mcp_project_dir(workspace, seat)? != project
|
|
430
|
+
|| materialize_cursor_mcp_project(workspace, seat, owner)? != project
|
|
431
|
+
{
|
|
432
|
+
return Err(refuse());
|
|
433
|
+
}
|
|
434
|
+
let command = server
|
|
435
|
+
.get("command")
|
|
436
|
+
.and_then(serde_json::Value::as_str)
|
|
437
|
+
.ok_or_else(refuse)?;
|
|
438
|
+
let metadata = std::fs::metadata(command).map_err(|_| refuse())?;
|
|
439
|
+
let current = std::env::current_exe()
|
|
440
|
+
.and_then(std::fs::canonicalize)
|
|
441
|
+
.map_err(|_| refuse())?;
|
|
442
|
+
if !Path::new(command).is_absolute()
|
|
443
|
+
|| !metadata.is_file()
|
|
444
|
+
|| metadata.mode() & 0o111 == 0
|
|
445
|
+
|| std::fs::canonicalize(command).map_err(|_| refuse())? != current
|
|
446
|
+
|| server.get("args") != Some(&serde_json::json!(["mcp-server", "--workspace", workspace]))
|
|
447
|
+
{
|
|
448
|
+
return Err(refuse());
|
|
449
|
+
}
|
|
450
|
+
Ok(())
|
|
451
|
+
}
|
|
452
|
+
|
|
346
453
|
pub(crate) fn apply_cursor_mcp_enable_profile_env(
|
|
347
454
|
command: &mut Command,
|
|
348
455
|
profile_launch: Option<&crate::provider::ProviderProfileLaunch>,
|