@team-agent/installer 0.5.2 → 0.5.4
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 +3 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +20 -0
- package/crates/team-agent/src/cli/diagnose.rs +191 -31
- package/crates/team-agent/src/cli/emit.rs +5 -0
- package/crates/team-agent/src/cli/mod.rs +213 -89
- package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
- package/crates/team-agent/src/codex_app_server.rs +62 -1
- package/crates/team-agent/src/conpty/backend.rs +120 -8
- package/crates/team-agent/src/coordinator/backoff.rs +88 -2
- package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
- package/crates/team-agent/src/coordinator/health.rs +97 -11
- package/crates/team-agent/src/coordinator/mod.rs +8 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
- package/crates/team-agent/src/coordinator/tick.rs +13 -0
- package/crates/team-agent/src/diagnose/orphans.rs +18 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
- package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
- package/crates/team-agent/src/lib.rs +14 -1
- package/crates/team-agent/src/lifecycle/launch.rs +89 -92
- package/crates/team-agent/src/lifecycle/lock.rs +40 -33
- package/crates/team-agent/src/lifecycle/restart/agent.rs +25 -19
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
- package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
- package/crates/team-agent/src/lifecycle/restart.rs +16 -6
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
- package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
- package/crates/team-agent/src/lifecycle/types.rs +1 -0
- package/crates/team-agent/src/mcp_server/wire.rs +6 -7
- package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
- package/crates/team-agent/src/packaging/tests.rs +41 -6
- package/crates/team-agent/src/packaging/types.rs +31 -3
- package/crates/team-agent/src/platform/argv.rs +324 -0
- package/crates/team-agent/src/platform/errors.rs +95 -0
- package/crates/team-agent/src/platform/file_lock.rs +418 -0
- package/crates/team-agent/src/platform/mod.rs +66 -0
- package/crates/team-agent/src/platform/process.rs +555 -0
- package/crates/team-agent/src/provider/adapter.rs +103 -41
- package/crates/team-agent/src/provider/session/capture.rs +328 -66
- package/crates/team-agent/src/provider/session/resume.rs +30 -28
- package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
- package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
- package/crates/team-agent/src/provider/session_scan.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +222 -25
- package/crates/team-agent/src/state/projection.rs +59 -4
- package/crates/team-agent/src/tmux_backend.rs +63 -13
- package/crates/team-agent/src/transport_factory.rs +124 -5
- package/package.json +4 -4
|
@@ -95,9 +95,14 @@ pub enum ResumeRefusalReason {
|
|
|
95
95
|
SessionCaptureIncomplete,
|
|
96
96
|
/// State session_id differs from the provider's observed session
|
|
97
97
|
/// (T6 L5.5 drift). Caller should reconcile before resuming.
|
|
98
|
-
SessionDrift {
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
SessionDrift { expected: String, actual: String },
|
|
99
|
+
/// The persisted provider backing exists, but the transcript itself
|
|
100
|
+
/// declares a different Team Agent worker identity.
|
|
101
|
+
SessionIdentityMismatch {
|
|
102
|
+
expected_agent_id: String,
|
|
103
|
+
embedded_agent_id: String,
|
|
104
|
+
session_id: String,
|
|
105
|
+
rollout_path: Option<PathBuf>,
|
|
101
106
|
},
|
|
102
107
|
/// Catch-all for refusals the structured shape hasn't taxonomized
|
|
103
108
|
/// yet. Carries the legacy free-form string. This variant exists so
|
|
@@ -118,11 +123,10 @@ impl ResumeRefusalReason {
|
|
|
118
123
|
ResumeRefusalReason::SessionBackingStoreMissing { .. } => {
|
|
119
124
|
"session_backing_store_missing"
|
|
120
125
|
}
|
|
121
|
-
ResumeRefusalReason::ProviderResumeUnsupported { .. } =>
|
|
122
|
-
"provider_resume_unsupported"
|
|
123
|
-
}
|
|
126
|
+
ResumeRefusalReason::ProviderResumeUnsupported { .. } => "provider_resume_unsupported",
|
|
124
127
|
ResumeRefusalReason::SessionCaptureIncomplete => "session_capture_incomplete",
|
|
125
128
|
ResumeRefusalReason::SessionDrift { .. } => "session_drift",
|
|
129
|
+
ResumeRefusalReason::SessionIdentityMismatch { .. } => "session_identity_mismatch",
|
|
126
130
|
// For Other we still report the legacy wire so the existing
|
|
127
131
|
// `session_unresumable` JSON shape is preserved end-to-end.
|
|
128
132
|
ResumeRefusalReason::Other { .. } => "session_unresumable",
|
|
@@ -134,16 +138,20 @@ impl ResumeRefusalReason {
|
|
|
134
138
|
pub fn from_legacy(reason: &str) -> Self {
|
|
135
139
|
match reason {
|
|
136
140
|
"no_persisted_session_id" => ResumeRefusalReason::NoSessionId,
|
|
137
|
-
"session_backing_store_missing" => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
}
|
|
141
|
+
"session_backing_store_missing" => ResumeRefusalReason::SessionBackingStoreMissing {
|
|
142
|
+
checked_paths: Vec::new(),
|
|
143
|
+
recovery_hint: None,
|
|
144
|
+
},
|
|
143
145
|
"provider_resume_unsupported" => ResumeRefusalReason::ProviderResumeUnsupported {
|
|
144
146
|
provider: String::new(),
|
|
145
147
|
},
|
|
146
148
|
"session_capture_incomplete" => ResumeRefusalReason::SessionCaptureIncomplete,
|
|
149
|
+
"session_identity_mismatch" => ResumeRefusalReason::SessionIdentityMismatch {
|
|
150
|
+
expected_agent_id: String::new(),
|
|
151
|
+
embedded_agent_id: String::new(),
|
|
152
|
+
session_id: String::new(),
|
|
153
|
+
rollout_path: None,
|
|
154
|
+
},
|
|
147
155
|
other => ResumeRefusalReason::Other {
|
|
148
156
|
legacy_reason: other.to_string(),
|
|
149
157
|
},
|
|
@@ -155,15 +163,11 @@ impl ResumeRefusalReason {
|
|
|
155
163
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
156
164
|
pub enum ResumePreflightOutcome {
|
|
157
165
|
/// Worker can resume from the named session.
|
|
158
|
-
Resume {
|
|
159
|
-
session_id: String,
|
|
160
|
-
},
|
|
166
|
+
Resume { session_id: String },
|
|
161
167
|
/// `--allow-fresh` was set; worker will start fresh.
|
|
162
168
|
FreshStart,
|
|
163
169
|
/// Resume refused. Caller MUST NOT proceed with teardown/spawn.
|
|
164
|
-
Refuse {
|
|
165
|
-
reason: ResumeRefusalReason,
|
|
166
|
-
},
|
|
170
|
+
Refuse { reason: ResumeRefusalReason },
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
/// Information about whether a provider backing file is present on disk.
|
|
@@ -239,9 +243,7 @@ impl ResumePreflight {
|
|
|
239
243
|
} else {
|
|
240
244
|
ResumePreflightOutcome::Refuse {
|
|
241
245
|
reason: ResumeRefusalReason::SessionBackingStoreMissing {
|
|
242
|
-
checked_paths: backing
|
|
243
|
-
.map(|b| b.paths.clone())
|
|
244
|
-
.unwrap_or_default(),
|
|
246
|
+
checked_paths: backing.map(|b| b.paths.clone()).unwrap_or_default(),
|
|
245
247
|
recovery_hint,
|
|
246
248
|
},
|
|
247
249
|
}
|
|
@@ -272,6 +274,7 @@ mod tests {
|
|
|
272
274
|
"session_backing_store_missing",
|
|
273
275
|
"provider_resume_unsupported",
|
|
274
276
|
"session_capture_incomplete",
|
|
277
|
+
"session_identity_mismatch",
|
|
275
278
|
] {
|
|
276
279
|
assert_eq!(ResumeRefusalReason::from_legacy(wire).wire(), wire);
|
|
277
280
|
}
|
|
@@ -383,10 +386,11 @@ mod tests {
|
|
|
383
386
|
);
|
|
384
387
|
match out {
|
|
385
388
|
ResumePreflightOutcome::Refuse {
|
|
386
|
-
reason:
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
389
|
+
reason:
|
|
390
|
+
ResumeRefusalReason::SessionBackingStoreMissing {
|
|
391
|
+
recovery_hint: Some(h),
|
|
392
|
+
..
|
|
393
|
+
},
|
|
390
394
|
} => {
|
|
391
395
|
assert_eq!(h, hint);
|
|
392
396
|
}
|
|
@@ -405,9 +409,7 @@ mod tests {
|
|
|
405
409
|
let out = ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", false);
|
|
406
410
|
match out {
|
|
407
411
|
ResumePreflightOutcome::Refuse {
|
|
408
|
-
reason: ResumeRefusalReason::SessionBackingStoreMissing {
|
|
409
|
-
recovery_hint, ..
|
|
410
|
-
},
|
|
412
|
+
reason: ResumeRefusalReason::SessionBackingStoreMissing { recovery_hint, .. },
|
|
411
413
|
} => {
|
|
412
414
|
assert!(recovery_hint.is_none());
|
|
413
415
|
}
|
|
@@ -98,7 +98,15 @@ pub(super) fn parse_candidate_files(
|
|
|
98
98
|
} else {
|
|
99
99
|
Confidence::Low
|
|
100
100
|
};
|
|
101
|
-
let
|
|
101
|
+
let embedded_agent_id = embedded_team_agent_worker_id_from_text(&text);
|
|
102
|
+
if embedded_agent_id
|
|
103
|
+
.as_deref()
|
|
104
|
+
.is_some_and(|id| id != context.agent_id.as_str())
|
|
105
|
+
{
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context)
|
|
109
|
+
|| embedded_agent_id.as_deref() == Some(context.agent_id.as_str());
|
|
102
110
|
let agent_path_match = candidate_path_matches_agent_id(&path, context);
|
|
103
111
|
if matches!(provider, Provider::Claude | Provider::ClaudeCode)
|
|
104
112
|
&& super::claude::records_have_leader_marker(&records)
|
|
@@ -113,6 +121,7 @@ pub(super) fn parse_candidate_files(
|
|
|
113
121
|
attribution_confidence,
|
|
114
122
|
spawn_cwd: context.spawn_cwd.clone(),
|
|
115
123
|
},
|
|
124
|
+
embedded_agent_id,
|
|
116
125
|
positive_agent_id_match,
|
|
117
126
|
agent_path_match,
|
|
118
127
|
});
|
|
@@ -308,6 +317,28 @@ fn candidate_text_has_team_agent_id(text: &str, context: &CaptureSessionContext)
|
|
|
308
317
|
.any(|needle| text.contains(needle))
|
|
309
318
|
}
|
|
310
319
|
|
|
320
|
+
pub(crate) fn embedded_team_agent_worker_id_from_text(text: &str) -> Option<String> {
|
|
321
|
+
const PREFIX: &str = "You are Team Agent worker `";
|
|
322
|
+
let start = text.find(PREFIX)? + PREFIX.len();
|
|
323
|
+
let rest = &text[start..];
|
|
324
|
+
let end = rest.find('`')?;
|
|
325
|
+
let id = &rest[..end];
|
|
326
|
+
if id.is_empty()
|
|
327
|
+
|| !id
|
|
328
|
+
.chars()
|
|
329
|
+
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
|
330
|
+
{
|
|
331
|
+
return None;
|
|
332
|
+
}
|
|
333
|
+
Some(id.to_string())
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
pub(crate) fn rollout_path_embedded_team_agent_worker_id(path: &Path) -> Option<String> {
|
|
337
|
+
read_head_text(path, CAPTURE_HEAD_BYTES)
|
|
338
|
+
.ok()
|
|
339
|
+
.and_then(|text| embedded_team_agent_worker_id_from_text(&text))
|
|
340
|
+
}
|
|
341
|
+
|
|
311
342
|
fn candidate_path_matches_agent_id(path: &Path, context: &CaptureSessionContext) -> bool {
|
|
312
343
|
let id = context.agent_id.as_str();
|
|
313
344
|
if id.is_empty() {
|
|
@@ -348,3 +379,88 @@ fn path_is_under_team_runtime(path: &Path) -> bool {
|
|
|
348
379
|
path.components()
|
|
349
380
|
.any(|c| c.as_os_str() == std::ffi::OsStr::new(".team"))
|
|
350
381
|
}
|
|
382
|
+
|
|
383
|
+
#[cfg(test)]
|
|
384
|
+
mod tests {
|
|
385
|
+
use super::*;
|
|
386
|
+
|
|
387
|
+
fn temp_dir(name: &str) -> PathBuf {
|
|
388
|
+
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
389
|
+
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
390
|
+
let dir =
|
|
391
|
+
std::env::temp_dir().join(format!("ta-session-scan-{name}-{}-{n}", std::process::id()));
|
|
392
|
+
std::fs::create_dir_all(&dir).expect("create temp dir");
|
|
393
|
+
dir
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fn write_codex_rollout(path: &Path, cwd: &Path, session_id: &str, embedded_agent_id: &str) {
|
|
397
|
+
let text = format!(
|
|
398
|
+
"{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
|
|
399
|
+
{{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
|
|
400
|
+
{{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
|
|
401
|
+
cwd.to_string_lossy()
|
|
402
|
+
);
|
|
403
|
+
std::fs::write(path, text).expect("write codex rollout");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#[test]
|
|
407
|
+
fn codex_prompt_worker_identity_is_a_positive_match() {
|
|
408
|
+
let dir = temp_dir("positive");
|
|
409
|
+
let rollout = dir.join("rollout-frontend.jsonl");
|
|
410
|
+
write_codex_rollout(&rollout, &dir, "sess-frontend", "frontend");
|
|
411
|
+
let context = CaptureSessionContext {
|
|
412
|
+
agent_id: "frontend".to_string(),
|
|
413
|
+
spawn_cwd: dir.clone(),
|
|
414
|
+
pane_id: None,
|
|
415
|
+
pane_pid: None,
|
|
416
|
+
spawned_at: None,
|
|
417
|
+
expected_session_id: None,
|
|
418
|
+
provider_projects_root: None,
|
|
419
|
+
};
|
|
420
|
+
let candidates = parse_candidate_files(
|
|
421
|
+
Provider::Codex,
|
|
422
|
+
&context,
|
|
423
|
+
vec![SessionCandidate {
|
|
424
|
+
path: rollout.clone(),
|
|
425
|
+
requires_cwd_match: false,
|
|
426
|
+
}],
|
|
427
|
+
);
|
|
428
|
+
assert_eq!(candidates.len(), 1);
|
|
429
|
+
assert!(
|
|
430
|
+
candidates[0].positive_agent_id_match,
|
|
431
|
+
"Codex transcript prompt identity must be treated as a positive worker id source"
|
|
432
|
+
);
|
|
433
|
+
let _ = std::fs::remove_file(&rollout);
|
|
434
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#[test]
|
|
438
|
+
fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
|
|
439
|
+
let dir = temp_dir("mismatch");
|
|
440
|
+
let rollout = dir.join("rollout-ios-dev.jsonl");
|
|
441
|
+
write_codex_rollout(&rollout, &dir, "sess-ios-dev", "ios-dev");
|
|
442
|
+
let context = CaptureSessionContext {
|
|
443
|
+
agent_id: "frontend".to_string(),
|
|
444
|
+
spawn_cwd: dir.clone(),
|
|
445
|
+
pane_id: None,
|
|
446
|
+
pane_pid: None,
|
|
447
|
+
spawned_at: None,
|
|
448
|
+
expected_session_id: None,
|
|
449
|
+
provider_projects_root: None,
|
|
450
|
+
};
|
|
451
|
+
let candidates = parse_candidate_files(
|
|
452
|
+
Provider::Codex,
|
|
453
|
+
&context,
|
|
454
|
+
vec![SessionCandidate {
|
|
455
|
+
path: rollout.clone(),
|
|
456
|
+
requires_cwd_match: false,
|
|
457
|
+
}],
|
|
458
|
+
);
|
|
459
|
+
assert!(
|
|
460
|
+
candidates.is_empty(),
|
|
461
|
+
"state agent=frontend must not accept a Codex rollout whose prompt says ios-dev"
|
|
462
|
+
);
|
|
463
|
+
let _ = std::fs::remove_file(&rollout);
|
|
464
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
@@ -49,6 +49,7 @@ pub struct CaptureSessionContext {
|
|
|
49
49
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
50
50
|
pub struct CapturedSessionCandidate {
|
|
51
51
|
pub captured: CapturedSession,
|
|
52
|
+
pub embedded_agent_id: Option<String>,
|
|
52
53
|
pub positive_agent_id_match: bool,
|
|
53
54
|
pub agent_path_match: bool,
|
|
54
55
|
}
|
|
@@ -146,7 +146,14 @@ fn errno_name(errno: Option<i32>) -> Option<&'static str> {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/// `runtime.py:_runtime_lock` 的 flock 版(RAII;Drop 释放)。state-save 不发锁事件。
|
|
149
|
-
///
|
|
149
|
+
///
|
|
150
|
+
/// 0.5.x Windows portability Batch 2: migrated to
|
|
151
|
+
/// `crate::platform::file_lock::{try_lock_once_nonblocking, unlock}` so
|
|
152
|
+
/// the same polling loop + timeout + `StateError::Locked(name)` shape
|
|
153
|
+
/// works on both Unix (`flock`) and Windows (`LockFileEx`) — 1:1
|
|
154
|
+
/// semantic mapping. The Batch 0 non-Unix `not_yet_implemented`
|
|
155
|
+
/// fallback is now removed (CR C-2 fallback burn-down; grep guard
|
|
156
|
+
/// `platform_fallback_burndown_batch0.rs` flipped in this batch).
|
|
150
157
|
struct RuntimeLock {
|
|
151
158
|
#[allow(dead_code)]
|
|
152
159
|
file: std::fs::File,
|
|
@@ -160,42 +167,37 @@ impl RuntimeLock {
|
|
|
160
167
|
}
|
|
161
168
|
let file = std::fs::OpenOptions::new()
|
|
162
169
|
.create(true)
|
|
170
|
+
.read(true)
|
|
163
171
|
.write(true)
|
|
164
172
|
.truncate(false)
|
|
165
173
|
.open(&lock_path)?;
|
|
166
|
-
|
|
167
|
-
{
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return Ok(Self { file });
|
|
174
|
+
let start = Instant::now();
|
|
175
|
+
loop {
|
|
176
|
+
match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
|
|
177
|
+
Ok(true) => return Ok(Self { file }),
|
|
178
|
+
Ok(false) => {
|
|
179
|
+
if start.elapsed().as_secs_f64() >= timeout {
|
|
180
|
+
return Err(StateError::Locked(name.to_string()));
|
|
181
|
+
}
|
|
182
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
176
183
|
}
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
Err(e) => {
|
|
185
|
+
// Byte-preserving: a real I/O error surfaces as a
|
|
186
|
+
// `StateError::Io` (via `From<io::Error>`), same as
|
|
187
|
+
// the current inline code would if `file.open()`
|
|
188
|
+
// failed. The lock-would-block case took the
|
|
189
|
+
// Ok(false) arm above.
|
|
190
|
+
return Err(StateError::from(e));
|
|
179
191
|
}
|
|
180
|
-
std::thread::sleep(Duration::from_millis(50));
|
|
181
192
|
}
|
|
182
193
|
}
|
|
183
|
-
#[cfg(not(unix))]
|
|
184
|
-
{
|
|
185
|
-
let _ = timeout;
|
|
186
|
-
Err(StateError::Locked(format!(
|
|
187
|
-
"{name} (runtime lock not yet implemented on non-unix)"
|
|
188
|
-
)))
|
|
189
|
-
}
|
|
190
194
|
}
|
|
191
195
|
}
|
|
192
196
|
|
|
193
|
-
#[cfg(unix)]
|
|
194
197
|
impl Drop for RuntimeLock {
|
|
195
198
|
fn drop(&mut self) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
|
|
199
|
+
// Best-effort unlock. OS releases on handle close if this fails.
|
|
200
|
+
let _ = crate::platform::file_lock::unlock(&self.file);
|
|
199
201
|
}
|
|
200
202
|
}
|
|
201
203
|
|
|
@@ -232,6 +234,23 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
|
|
|
232
234
|
save_runtime_state_with_merge_options(workspace, state, &[], None, &[], agent_ids)
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
|
|
238
|
+
workspace: &Path,
|
|
239
|
+
state: &Value,
|
|
240
|
+
skip_capture_backfill_team_key: &str,
|
|
241
|
+
skip_capture_backfill_agent_ids: &[&str],
|
|
242
|
+
topology_agent_ids: &[&str],
|
|
243
|
+
) -> Result<(), StateError> {
|
|
244
|
+
save_runtime_state_with_merge_options(
|
|
245
|
+
workspace,
|
|
246
|
+
state,
|
|
247
|
+
&[],
|
|
248
|
+
Some(skip_capture_backfill_team_key),
|
|
249
|
+
skip_capture_backfill_agent_ids,
|
|
250
|
+
topology_agent_ids,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
235
254
|
pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
236
255
|
workspace: &Path,
|
|
237
256
|
state: &Value,
|
|
@@ -451,6 +470,18 @@ fn apply_persist_merge_contract(
|
|
|
451
470
|
// teams.<key> entry-scoped preservation at :370 remains so legacy
|
|
452
471
|
// teams.<key>.team_owner entries still survive a save that omits
|
|
453
472
|
// them.
|
|
473
|
+
|
|
474
|
+
// 0.5.x Windows portability Batch 7 F6 (leader msg_700a016675d0):
|
|
475
|
+
// preserve `state.transport.shim` across saves. The coordinator
|
|
476
|
+
// writes this block when it spawns `windows-shim.exe` during
|
|
477
|
+
// quick-start (see `coordinator::conpty_shim::finalize`).
|
|
478
|
+
// Downstream saves inside the same quick-start (leader-persist,
|
|
479
|
+
// worker-persist) construct their `incoming` state from an
|
|
480
|
+
// in-memory copy that predates the shim-spawn, so without this
|
|
481
|
+
// preserve step the shim block is silently dropped. That
|
|
482
|
+
// clobber broke Batch 6 CHECK 6 (`shutdown` couldn't route to
|
|
483
|
+
// the shim pid because `state.transport.shim.pid` was gone).
|
|
484
|
+
preserve_transport_shim(incoming, latest);
|
|
454
485
|
}
|
|
455
486
|
|
|
456
487
|
let latest_teams = latest.get("teams").and_then(Value::as_object);
|
|
@@ -478,6 +509,47 @@ fn apply_persist_merge_contract(
|
|
|
478
509
|
Ok(())
|
|
479
510
|
}
|
|
480
511
|
|
|
512
|
+
/// 0.5.x Windows portability Batch 7 F6: copy `latest.transport.shim`
|
|
513
|
+
/// into `incoming` if the incoming save omitted it. The coordinator's
|
|
514
|
+
/// `conpty_shim::finalize` writes `state.transport.shim = {pid,
|
|
515
|
+
/// pipe_name, pipe_ready}` after Hello succeeds. If a subsequent save
|
|
516
|
+
/// in the same quick-start starts from a stale in-memory `Value` that
|
|
517
|
+
/// predates the finalize, this preserve step keeps the on-disk shim
|
|
518
|
+
/// block alive.
|
|
519
|
+
///
|
|
520
|
+
/// Only runs when the top-level projection matches (same active team
|
|
521
|
+
/// key + same session name) — no risk of leaking a shim block into
|
|
522
|
+
/// an unrelated projection.
|
|
523
|
+
///
|
|
524
|
+
/// Windows-relevant behavior; on Unix `transport.shim` is never
|
|
525
|
+
/// written, so this fn is a no-op (both branches see `None`).
|
|
526
|
+
fn preserve_transport_shim(incoming: &mut Value, latest: &Value) {
|
|
527
|
+
// The shim block only matters when `latest` has one AND
|
|
528
|
+
// `incoming` doesn't (or `incoming.transport` doesn't exist).
|
|
529
|
+
let Some(latest_shim) = latest
|
|
530
|
+
.get("transport")
|
|
531
|
+
.and_then(|t| t.get("shim"))
|
|
532
|
+
.cloned()
|
|
533
|
+
else {
|
|
534
|
+
return;
|
|
535
|
+
};
|
|
536
|
+
let Some(incoming_obj) = incoming.as_object_mut() else {
|
|
537
|
+
return;
|
|
538
|
+
};
|
|
539
|
+
let transport = incoming_obj
|
|
540
|
+
.entry("transport".to_string())
|
|
541
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
542
|
+
let Some(transport_obj) = transport.as_object_mut() else {
|
|
543
|
+
return;
|
|
544
|
+
};
|
|
545
|
+
// If the incoming already has a shim block, respect it (the
|
|
546
|
+
// caller has authoritative newer state — e.g. shim was rotated).
|
|
547
|
+
if transport_obj.contains_key("shim") {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
transport_obj.insert("shim".to_string(), latest_shim);
|
|
551
|
+
}
|
|
552
|
+
|
|
481
553
|
fn should_skip_capture_backfill(
|
|
482
554
|
current_team_key: Option<&str>,
|
|
483
555
|
skip_team_key: Option<&str>,
|
|
@@ -1696,6 +1768,131 @@ mod tests {
|
|
|
1696
1768
|
);
|
|
1697
1769
|
}
|
|
1698
1770
|
|
|
1771
|
+
// 0.5.x Windows portability Batch 7 F6 (leader msg_700a016675d0):
|
|
1772
|
+
// `state.transport.shim` must survive a downstream save whose
|
|
1773
|
+
// `incoming` was constructed from a stale in-memory Value.
|
|
1774
|
+
#[test]
|
|
1775
|
+
fn transport_shim_block_is_preserved_across_merge() {
|
|
1776
|
+
let ws = temp_ws();
|
|
1777
|
+
// Disk state has the shim block (coordinator wrote it after
|
|
1778
|
+
// Hello succeeded).
|
|
1779
|
+
write_state(
|
|
1780
|
+
&ws,
|
|
1781
|
+
&json!({
|
|
1782
|
+
"session_name": "team-a",
|
|
1783
|
+
"active_team_key": "team-a",
|
|
1784
|
+
"agents": {},
|
|
1785
|
+
"transport": {
|
|
1786
|
+
"kind": "conpty",
|
|
1787
|
+
"shim": { "pid": 4242, "pipe_name": r"\\.\pipe\team-agent-conpty-abc-team-a", "pipe_ready": true }
|
|
1788
|
+
}
|
|
1789
|
+
}),
|
|
1790
|
+
);
|
|
1791
|
+
// Incoming save (leader-persist or worker-persist) doesn't
|
|
1792
|
+
// know about the shim block.
|
|
1793
|
+
let incoming = json!({
|
|
1794
|
+
"session_name": "team-a",
|
|
1795
|
+
"active_team_key": "team-a",
|
|
1796
|
+
"agents": {},
|
|
1797
|
+
"transport": {
|
|
1798
|
+
"kind": "conpty"
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
save_runtime_state(&ws, &incoming).unwrap();
|
|
1802
|
+
let saved = read_state(&ws);
|
|
1803
|
+
// Shim block must survive the merge.
|
|
1804
|
+
assert_eq!(
|
|
1805
|
+
saved.pointer("/transport/shim/pid").and_then(Value::as_u64),
|
|
1806
|
+
Some(4242),
|
|
1807
|
+
"shim.pid must be preserved: {saved}"
|
|
1808
|
+
);
|
|
1809
|
+
assert_eq!(
|
|
1810
|
+
saved
|
|
1811
|
+
.pointer("/transport/shim/pipe_name")
|
|
1812
|
+
.and_then(Value::as_str),
|
|
1813
|
+
Some(r"\\.\pipe\team-agent-conpty-abc-team-a"),
|
|
1814
|
+
);
|
|
1815
|
+
assert_eq!(
|
|
1816
|
+
saved
|
|
1817
|
+
.pointer("/transport/shim/pipe_ready")
|
|
1818
|
+
.and_then(Value::as_bool),
|
|
1819
|
+
Some(true),
|
|
1820
|
+
);
|
|
1821
|
+
// And the incoming's `transport.kind` still wins.
|
|
1822
|
+
assert_eq!(
|
|
1823
|
+
saved.pointer("/transport/kind").and_then(Value::as_str),
|
|
1824
|
+
Some("conpty"),
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
#[test]
|
|
1829
|
+
fn transport_shim_incoming_authoritative_overrides_latest() {
|
|
1830
|
+
// If the caller has a NEWER shim block (e.g. rotation), it
|
|
1831
|
+
// wins — preserve_transport_shim only fills in the gap.
|
|
1832
|
+
let ws = temp_ws();
|
|
1833
|
+
write_state(
|
|
1834
|
+
&ws,
|
|
1835
|
+
&json!({
|
|
1836
|
+
"session_name": "team-a",
|
|
1837
|
+
"active_team_key": "team-a",
|
|
1838
|
+
"agents": {},
|
|
1839
|
+
"transport": {
|
|
1840
|
+
"kind": "conpty",
|
|
1841
|
+
"shim": { "pid": 1000, "pipe_name": "old", "pipe_ready": true }
|
|
1842
|
+
}
|
|
1843
|
+
}),
|
|
1844
|
+
);
|
|
1845
|
+
let incoming = json!({
|
|
1846
|
+
"session_name": "team-a",
|
|
1847
|
+
"active_team_key": "team-a",
|
|
1848
|
+
"agents": {},
|
|
1849
|
+
"transport": {
|
|
1850
|
+
"kind": "conpty",
|
|
1851
|
+
"shim": { "pid": 9999, "pipe_name": "new", "pipe_ready": true }
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
save_runtime_state(&ws, &incoming).unwrap();
|
|
1855
|
+
let saved = read_state(&ws);
|
|
1856
|
+
assert_eq!(
|
|
1857
|
+
saved.pointer("/transport/shim/pid").and_then(Value::as_u64),
|
|
1858
|
+
Some(9999),
|
|
1859
|
+
"incoming shim block must win when both sides have one"
|
|
1860
|
+
);
|
|
1861
|
+
assert_eq!(
|
|
1862
|
+
saved
|
|
1863
|
+
.pointer("/transport/shim/pipe_name")
|
|
1864
|
+
.and_then(Value::as_str),
|
|
1865
|
+
Some("new"),
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
#[test]
|
|
1870
|
+
fn transport_shim_no_leak_when_latest_lacks_block() {
|
|
1871
|
+
// If disk has no shim block, save must not synthesize one.
|
|
1872
|
+
let ws = temp_ws();
|
|
1873
|
+
write_state(
|
|
1874
|
+
&ws,
|
|
1875
|
+
&json!({
|
|
1876
|
+
"session_name": "team-a",
|
|
1877
|
+
"active_team_key": "team-a",
|
|
1878
|
+
"agents": {},
|
|
1879
|
+
"transport": {"kind": "tmux"}
|
|
1880
|
+
}),
|
|
1881
|
+
);
|
|
1882
|
+
let incoming = json!({
|
|
1883
|
+
"session_name": "team-a",
|
|
1884
|
+
"active_team_key": "team-a",
|
|
1885
|
+
"agents": {},
|
|
1886
|
+
"transport": {"kind": "tmux"}
|
|
1887
|
+
});
|
|
1888
|
+
save_runtime_state(&ws, &incoming).unwrap();
|
|
1889
|
+
let saved = read_state(&ws);
|
|
1890
|
+
assert!(
|
|
1891
|
+
saved.pointer("/transport/shim").is_none(),
|
|
1892
|
+
"shim must not appear when neither side had one: {saved}"
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1699
1896
|
#[test]
|
|
1700
1897
|
fn projection_paths_do_not_cross_backfill_topology() {
|
|
1701
1898
|
let ws = temp_ws();
|