@team-agent/installer 0.5.74 → 0.5.75
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/layout/worker_env.rs +366 -0
- package/crates/team-agent/src/leader/lease.rs +228 -32
- package/crates/team-agent/src/leader/registry.rs +177 -19
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
- package/crates/team-agent/src/messaging/delivery.rs +200 -29
- package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
- package/crates/team-agent/src/state/persist.rs +166 -6
- package/crates/team-agent/src/state/repository.rs +36 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
- package/crates/team-agent/src/tmux_backend.rs +217 -26
- package/crates/team-agent/src/transport/test_support.rs +18 -3
- package/crates/team-agent/src/transport.rs +99 -0
- package/package.json +4 -4
|
@@ -26,6 +26,41 @@ use super::{
|
|
|
26
26
|
|
|
27
27
|
// ── leader::lease — attach / claim / takeover / autobind / readopt 统一 CAS 路径 ──
|
|
28
28
|
|
|
29
|
+
#[derive(Debug, Clone)]
|
|
30
|
+
pub(crate) struct AppliedLeaderGrant {
|
|
31
|
+
pub(crate) owner: Value,
|
|
32
|
+
pub(crate) receiver: Value,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[derive(Debug)]
|
|
36
|
+
pub(crate) struct AttachLeaderError {
|
|
37
|
+
source: LeaderError,
|
|
38
|
+
applied_grant: Option<AppliedLeaderGrant>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl AttachLeaderError {
|
|
42
|
+
fn with_applied(source: LeaderError, applied_grant: Option<AppliedLeaderGrant>) -> Self {
|
|
43
|
+
Self {
|
|
44
|
+
source,
|
|
45
|
+
applied_grant,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pub(crate) fn into_parts(self) -> (LeaderError, Option<AppliedLeaderGrant>) {
|
|
50
|
+
(self.source, self.applied_grant)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn into_error(self) -> LeaderError {
|
|
54
|
+
self.source
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl From<LeaderError> for AttachLeaderError {
|
|
59
|
+
fn from(source: LeaderError) -> Self {
|
|
60
|
+
Self::with_applied(source, None)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
/// `attach_leader`(card §42;`__init__.py:19`)。手动 CLI attach;持 `LEADER_OWNERSHIP_LOCK`
|
|
30
65
|
/// 整段临界区做 state 变更 + 事件 + 双写 + requeue exhausted watchers。
|
|
31
66
|
pub fn attach_leader(
|
|
@@ -454,63 +489,140 @@ pub(crate) fn attach_leader_to_state(
|
|
|
454
489
|
source: LeaseSource,
|
|
455
490
|
require_current: bool,
|
|
456
491
|
) -> Result<(LeaderReceiver, Value), LeaderError> {
|
|
457
|
-
|
|
492
|
+
attach_leader_to_state_with_target_and_controls(
|
|
493
|
+
workspace,
|
|
494
|
+
state,
|
|
495
|
+
pane,
|
|
496
|
+
provider,
|
|
497
|
+
event_log,
|
|
498
|
+
source,
|
|
499
|
+
require_current,
|
|
500
|
+
None,
|
|
501
|
+
None,
|
|
502
|
+
None,
|
|
503
|
+
None,
|
|
504
|
+
)
|
|
505
|
+
.map_err(AttachLeaderError::into_error)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#[allow(clippy::too_many_arguments)]
|
|
509
|
+
pub(crate) fn attach_leader_to_state_with_target(
|
|
510
|
+
workspace: &Path,
|
|
511
|
+
state: &mut Value,
|
|
512
|
+
pane: Option<&PaneId>,
|
|
513
|
+
provider: Provider,
|
|
514
|
+
event_log: &crate::event_log::EventLog,
|
|
515
|
+
source: LeaseSource,
|
|
516
|
+
require_current: bool,
|
|
517
|
+
caller_target: Option<&PaneInfo>,
|
|
518
|
+
) -> Result<(LeaderReceiver, Value), LeaderError> {
|
|
519
|
+
attach_leader_to_state_with_target_and_controls(
|
|
520
|
+
workspace,
|
|
521
|
+
state,
|
|
522
|
+
pane,
|
|
523
|
+
provider,
|
|
524
|
+
event_log,
|
|
525
|
+
source,
|
|
526
|
+
require_current,
|
|
527
|
+
caller_target,
|
|
528
|
+
None,
|
|
529
|
+
None,
|
|
530
|
+
None,
|
|
531
|
+
)
|
|
532
|
+
.map_err(AttachLeaderError::into_error)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#[allow(clippy::too_many_arguments)]
|
|
536
|
+
pub(crate) fn attach_leader_to_state_with_target_and_controls(
|
|
537
|
+
workspace: &Path,
|
|
538
|
+
state: &mut Value,
|
|
539
|
+
pane: Option<&PaneId>,
|
|
540
|
+
provider: Provider,
|
|
541
|
+
event_log: &crate::event_log::EventLog,
|
|
542
|
+
source: LeaseSource,
|
|
543
|
+
require_current: bool,
|
|
544
|
+
caller_target: Option<&PaneInfo>,
|
|
545
|
+
expected_owner: Option<&Value>,
|
|
546
|
+
expected_receiver: Option<&Value>,
|
|
547
|
+
nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
|
|
548
|
+
) -> Result<(LeaderReceiver, Value), AttachLeaderError> {
|
|
549
|
+
if expected_owner.is_some() != expected_receiver.is_some() {
|
|
550
|
+
return Err(AttachLeaderError::from(LeaderError::Validation(
|
|
551
|
+
"fresh caller CAS requires both expected owner and receiver".to_string(),
|
|
552
|
+
)));
|
|
553
|
+
}
|
|
458
554
|
let pane_id = pane
|
|
459
555
|
.cloned()
|
|
460
556
|
.ok_or_else(|| LeaderError::Validation("tmux pane not found".to_string()))?;
|
|
461
557
|
let non_empty_pane_id = NonEmptyPaneId::try_from_pane(&pane_id)?;
|
|
462
558
|
let identity = leader_identity_context(workspace, None, Some(state))?;
|
|
463
559
|
let epoch = current_owner_epoch(state);
|
|
464
|
-
let
|
|
560
|
+
let has_owner = state.get("team_owner").is_some();
|
|
561
|
+
let binding_epoch = if has_owner {
|
|
562
|
+
epoch
|
|
563
|
+
} else {
|
|
564
|
+
OwnerEpoch(epoch.0.saturating_add(1))
|
|
565
|
+
};
|
|
566
|
+
let mut receiver = make_receiver(
|
|
465
567
|
provider,
|
|
466
568
|
&non_empty_pane_id,
|
|
467
569
|
&identity.leader_session_uuid,
|
|
468
|
-
|
|
570
|
+
binding_epoch,
|
|
469
571
|
Discovery::EnvPane,
|
|
470
572
|
None,
|
|
471
573
|
);
|
|
472
|
-
|
|
574
|
+
authorize_fresh_caller_receiver(
|
|
575
|
+
workspace,
|
|
576
|
+
&mut receiver,
|
|
577
|
+
&non_empty_pane_id,
|
|
578
|
+
source,
|
|
579
|
+
require_current,
|
|
580
|
+
caller_target,
|
|
581
|
+
nonce_writer,
|
|
582
|
+
)?;
|
|
583
|
+
let receiver_value = serde_json::to_value(&receiver).map_err(LeaderError::from)?;
|
|
584
|
+
if has_owner {
|
|
473
585
|
write_receiver_to_state(state, &receiver)?;
|
|
474
586
|
} else {
|
|
475
|
-
let
|
|
476
|
-
let receiver = make_receiver(
|
|
477
|
-
provider,
|
|
478
|
-
&non_empty_pane_id,
|
|
479
|
-
&identity.leader_session_uuid,
|
|
480
|
-
next_epoch,
|
|
481
|
-
Discovery::EnvPane,
|
|
482
|
-
None,
|
|
483
|
-
);
|
|
484
|
-
let owner = make_owner(provider, &non_empty_pane_id, &identity, next_epoch);
|
|
587
|
+
let owner = make_owner(provider, &non_empty_pane_id, &identity, binding_epoch);
|
|
485
588
|
write_binding_to_state(state, &receiver, &owner)?;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
|
|
490
|
-
)?;
|
|
491
|
-
let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
|
|
492
|
-
crate::messaging::watchers::recover_watchers_for_incident(
|
|
589
|
+
}
|
|
590
|
+
if let (Some(expected_owner), Some(expected_receiver)) = (expected_owner, expected_receiver) {
|
|
591
|
+
write_lease_dual_state_with_expected(
|
|
493
592
|
workspace,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
LeaderIncident::LeaderAttached,
|
|
593
|
+
state,
|
|
594
|
+
expected_owner,
|
|
595
|
+
expected_receiver,
|
|
498
596
|
)?;
|
|
499
|
-
|
|
597
|
+
} else {
|
|
598
|
+
write_lease_dual_state(workspace, state)?;
|
|
500
599
|
}
|
|
501
|
-
|
|
502
|
-
|
|
600
|
+
let applied_grant = state
|
|
601
|
+
.get("team_owner")
|
|
602
|
+
.cloned()
|
|
603
|
+
.map(|owner| AppliedLeaderGrant {
|
|
604
|
+
owner,
|
|
605
|
+
receiver: receiver_value,
|
|
606
|
+
});
|
|
607
|
+
if let Err(error) = event_log.write(
|
|
503
608
|
super::LeaderEvent::ReceiverAttached.name(),
|
|
504
|
-
json!({"pane_id": pane_id.as_str(), "owner_epoch":
|
|
505
|
-
)
|
|
609
|
+
json!({"pane_id": pane_id.as_str(), "owner_epoch": binding_epoch.0}),
|
|
610
|
+
) {
|
|
611
|
+
return Err(AttachLeaderError::with_applied(
|
|
612
|
+
error.into(),
|
|
613
|
+
applied_grant.clone(),
|
|
614
|
+
));
|
|
615
|
+
}
|
|
506
616
|
let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
|
|
507
|
-
crate::messaging::watchers::recover_watchers_for_incident(
|
|
617
|
+
if let Err(error) = crate::messaging::watchers::recover_watchers_for_incident(
|
|
508
618
|
workspace,
|
|
509
619
|
event_log,
|
|
510
620
|
&team_id,
|
|
511
621
|
&pane_id,
|
|
512
622
|
LeaderIncident::LeaderAttached,
|
|
513
|
-
)
|
|
623
|
+
) {
|
|
624
|
+
return Err(AttachLeaderError::with_applied(error.into(), applied_grant));
|
|
625
|
+
}
|
|
514
626
|
Ok((receiver, json!({"ok": true})))
|
|
515
627
|
}
|
|
516
628
|
|
|
@@ -1870,6 +1982,72 @@ fn emit_lease_refusal(
|
|
|
1870
1982
|
Ok(())
|
|
1871
1983
|
}
|
|
1872
1984
|
|
|
1985
|
+
fn authorize_fresh_caller_receiver(
|
|
1986
|
+
workspace: &Path,
|
|
1987
|
+
receiver: &mut LeaderReceiver,
|
|
1988
|
+
pane: &NonEmptyPaneId,
|
|
1989
|
+
source: LeaseSource,
|
|
1990
|
+
require_current: bool,
|
|
1991
|
+
caller_target: Option<&PaneInfo>,
|
|
1992
|
+
nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
|
|
1993
|
+
) -> Result<(), LeaderError> {
|
|
1994
|
+
if !matches!(source, LeaseSource::QuickStart) || !require_current {
|
|
1995
|
+
return Ok(());
|
|
1996
|
+
}
|
|
1997
|
+
let Some(target) = caller_target else {
|
|
1998
|
+
return Ok(());
|
|
1999
|
+
};
|
|
2000
|
+
if target.pane_id.as_str() != pane.as_pane_id().as_str() || !target.active {
|
|
2001
|
+
return Err(LeaderError::Validation(
|
|
2002
|
+
"fresh caller pane observation does not match the bound pane".to_string(),
|
|
2003
|
+
));
|
|
2004
|
+
}
|
|
2005
|
+
let Some(current_path) = target.current_path.as_deref() else {
|
|
2006
|
+
return Ok(());
|
|
2007
|
+
};
|
|
2008
|
+
if crate::messaging::leader_channel::path_is_in_workspace(current_path, workspace) {
|
|
2009
|
+
return Ok(());
|
|
2010
|
+
}
|
|
2011
|
+
let Some(endpoint) = receiver
|
|
2012
|
+
.tmux_socket
|
|
2013
|
+
.as_deref()
|
|
2014
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
2015
|
+
else {
|
|
2016
|
+
return Err(LeaderError::Validation(
|
|
2017
|
+
"fresh cross-workspace caller requires a tmux endpoint".to_string(),
|
|
2018
|
+
));
|
|
2019
|
+
};
|
|
2020
|
+
if !Path::new(endpoint).is_absolute() {
|
|
2021
|
+
return Err(LeaderError::Validation(
|
|
2022
|
+
"fresh cross-workspace caller requires an absolute tmux endpoint".to_string(),
|
|
2023
|
+
));
|
|
2024
|
+
}
|
|
2025
|
+
let pane_id = pane.as_pane_id();
|
|
2026
|
+
let live_nonce = if let Some(live_nonce) = target
|
|
2027
|
+
.leader_env
|
|
2028
|
+
.get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
|
|
2029
|
+
.filter(|nonce| !nonce.is_empty())
|
|
2030
|
+
.cloned()
|
|
2031
|
+
{
|
|
2032
|
+
live_nonce
|
|
2033
|
+
} else {
|
|
2034
|
+
let nonce = pane_binding_nonce_for_claim(workspace, endpoint, pane_id, None);
|
|
2035
|
+
if let Some(writer) = nonce_writer {
|
|
2036
|
+
writer(endpoint, pane_id, &nonce)?
|
|
2037
|
+
} else {
|
|
2038
|
+
tmux_backend_for_endpoint(endpoint)
|
|
2039
|
+
.set_pane_binding_nonce_if_unset(pane_id, &nonce)
|
|
2040
|
+
.map_err(|error| LeaderError::Tmux(error.to_string()))?
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
receiver.scope_authority = Some(
|
|
2044
|
+
crate::messaging::leader_channel::FRESH_CALLER_SCOPE_AUTHORITY.to_string(),
|
|
2045
|
+
);
|
|
2046
|
+
receiver.authorized_team_workspace = Some(canonical_workspace(workspace));
|
|
2047
|
+
receiver.binding_nonce = Some(live_nonce);
|
|
2048
|
+
Ok(())
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1873
2051
|
fn make_receiver(
|
|
1874
2052
|
provider: Provider,
|
|
1875
2053
|
pane: &NonEmptyPaneId,
|
|
@@ -2237,6 +2415,24 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
|
|
|
2237
2415
|
/// now persists ONLY the canonical root state; retaining the public
|
|
2238
2416
|
/// name for 0.5.x call-site stability. The B0 legacy snapshot is
|
|
2239
2417
|
/// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
|
|
2418
|
+
pub(crate) fn write_lease_dual_state_with_expected(
|
|
2419
|
+
workspace: &Path,
|
|
2420
|
+
state: &Value,
|
|
2421
|
+
expected_owner: &Value,
|
|
2422
|
+
expected_receiver: &Value,
|
|
2423
|
+
) -> Result<(), LeaderError> {
|
|
2424
|
+
let team_key = canonical_owner_write_key(state);
|
|
2425
|
+
crate::state::repository::StateRepository::new(workspace).save(
|
|
2426
|
+
crate::state::repository::StateWriteIntent::ClaimLeaderFreshCaller {
|
|
2427
|
+
team_key: team_key.as_str(),
|
|
2428
|
+
expected_owner,
|
|
2429
|
+
expected_receiver,
|
|
2430
|
+
},
|
|
2431
|
+
state,
|
|
2432
|
+
)?;
|
|
2433
|
+
Ok(())
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2240
2436
|
pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
|
|
2241
2437
|
// 0.5.42 S1b (s1b-writer-cluster-locate.md §4.4): terminal root
|
|
2242
2438
|
// save routes through `StateRepository` with the `ClaimLeader`
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
22
22
|
|
|
23
|
+
use std::fmt;
|
|
23
24
|
use std::path::{Path, PathBuf};
|
|
25
|
+
use std::time::{Duration, Instant};
|
|
24
26
|
|
|
25
27
|
use serde::{Deserialize, Serialize};
|
|
26
28
|
use serde_json::{json, Value};
|
|
@@ -100,10 +102,45 @@ pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
|
|
|
100
102
|
/// a fully-qualified form.
|
|
101
103
|
pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
|
|
102
104
|
|
|
105
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
106
|
+
pub enum RegistryRollback {
|
|
107
|
+
Restored,
|
|
108
|
+
Superseded,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
112
|
+
pub enum RegistryRollbackError {
|
|
113
|
+
InvalidReceipt,
|
|
114
|
+
LockUnavailable,
|
|
115
|
+
ReadFailed,
|
|
116
|
+
RestoreFailed,
|
|
117
|
+
DeleteFailed,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
impl fmt::Display for RegistryRollbackError {
|
|
121
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
122
|
+
let reason = match self {
|
|
123
|
+
Self::InvalidReceipt => "invalid receipt",
|
|
124
|
+
Self::LockUnavailable => "registry lock unavailable",
|
|
125
|
+
Self::ReadFailed => "registry receipt read failed",
|
|
126
|
+
Self::RestoreFailed => "registry restore failed",
|
|
127
|
+
Self::DeleteFailed => "registry delete failed",
|
|
128
|
+
};
|
|
129
|
+
f.write_str(reason)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
134
|
+
pub struct RegistryWriteReceipt {
|
|
135
|
+
pub path: PathBuf,
|
|
136
|
+
pub bytes: Vec<u8>,
|
|
137
|
+
}
|
|
138
|
+
|
|
103
139
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
104
140
|
pub struct RegistryWriteOutcome {
|
|
105
141
|
pub status: &'static str,
|
|
106
142
|
pub path: Option<PathBuf>,
|
|
143
|
+
pub receipt: Option<RegistryWriteReceipt>,
|
|
107
144
|
pub team_key: String,
|
|
108
145
|
pub workspace_hash: String,
|
|
109
146
|
pub source: String,
|
|
@@ -242,7 +279,8 @@ pub fn register_binding_from_state_best_effort(
|
|
|
242
279
|
chrono::Utc::now().to_rfc3339(),
|
|
243
280
|
);
|
|
244
281
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
245
|
-
let
|
|
282
|
+
let write_receipt = write_entry_best_effort_with_receipt(&entry);
|
|
283
|
+
let write_result = write_receipt.as_ref().map(|receipt| receipt.path.clone());
|
|
246
284
|
let status = if let Some(path) = &write_result {
|
|
247
285
|
let _ = event_log.write(
|
|
248
286
|
EVENT_REGISTERED,
|
|
@@ -268,6 +306,7 @@ pub fn register_binding_from_state_best_effort(
|
|
|
268
306
|
Some(RegistryWriteOutcome {
|
|
269
307
|
status,
|
|
270
308
|
path: write_result,
|
|
309
|
+
receipt: write_receipt,
|
|
271
310
|
team_key,
|
|
272
311
|
workspace_hash: entry.workspace_hash,
|
|
273
312
|
source: source.to_string(),
|
|
@@ -281,13 +320,19 @@ fn entry_filename(entry: &LeaderRegistryEntry) -> String {
|
|
|
281
320
|
|
|
282
321
|
/// Registry write is best-effort. Writes to `.<file>.tmp-<pid>-<counter>`
|
|
283
322
|
/// first, then atomically renames to `<workspace_hash>__<team_key>.json`.
|
|
284
|
-
///
|
|
285
|
-
///
|
|
323
|
+
/// Every writer uses the registry lock so conditional rollback can compare
|
|
324
|
+
/// and restore without racing a concurrent publish.
|
|
286
325
|
pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
326
|
+
write_entry_best_effort_with_receipt(entry).map(|receipt| receipt.path)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fn write_entry_best_effort_with_receipt(
|
|
330
|
+
entry: &LeaderRegistryEntry,
|
|
331
|
+
) -> Option<RegistryWriteReceipt> {
|
|
332
|
+
with_registry_lock(|dir| write_entry_locked(dir, entry)).flatten()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
fn write_entry_locked(dir: &Path, entry: &LeaderRegistryEntry) -> Option<RegistryWriteReceipt> {
|
|
291
336
|
let final_path = dir.join(entry_filename(entry));
|
|
292
337
|
let tmp_name = format!(
|
|
293
338
|
".{}.tmp-{}-{}",
|
|
@@ -296,8 +341,8 @@ pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
|
|
|
296
341
|
rand_suffix()
|
|
297
342
|
);
|
|
298
343
|
let tmp_path = dir.join(tmp_name);
|
|
299
|
-
let serialized = serde_json::
|
|
300
|
-
if std::fs::write(&tmp_path, serialized).is_err() {
|
|
344
|
+
let serialized = serde_json::to_vec_pretty(entry).ok()?;
|
|
345
|
+
if std::fs::write(&tmp_path, &serialized).is_err() {
|
|
301
346
|
let _ = std::fs::remove_file(&tmp_path);
|
|
302
347
|
return None;
|
|
303
348
|
}
|
|
@@ -305,7 +350,118 @@ pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
|
|
|
305
350
|
let _ = std::fs::remove_file(&tmp_path);
|
|
306
351
|
return None;
|
|
307
352
|
}
|
|
308
|
-
Some(
|
|
353
|
+
Some(RegistryWriteReceipt {
|
|
354
|
+
path: final_path,
|
|
355
|
+
bytes: serialized,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// Restore one registry path only when it still contains this writer's exact
|
|
360
|
+
/// receipt. The comparison and restore/delete are held under the same lock as
|
|
361
|
+
/// ordinary registry writers, so a winner cannot slip in between them.
|
|
362
|
+
pub fn restore_entry_if_current_matches(
|
|
363
|
+
receipt: &RegistryWriteReceipt,
|
|
364
|
+
previous: Option<&[u8]>,
|
|
365
|
+
) -> Result<RegistryRollback, RegistryRollbackError> {
|
|
366
|
+
with_registry_lock_result(|dir| {
|
|
367
|
+
if receipt.path.parent() != Some(dir) {
|
|
368
|
+
return Err(RegistryRollbackError::InvalidReceipt);
|
|
369
|
+
}
|
|
370
|
+
let current = std::fs::read(&receipt.path)
|
|
371
|
+
.map_err(|_| RegistryRollbackError::ReadFailed)?;
|
|
372
|
+
if current != receipt.bytes {
|
|
373
|
+
return Ok(RegistryRollback::Superseded);
|
|
374
|
+
}
|
|
375
|
+
match previous {
|
|
376
|
+
Some(bytes) if replace_entry_bytes_locked(&receipt.path, bytes) => {
|
|
377
|
+
Ok(RegistryRollback::Restored)
|
|
378
|
+
}
|
|
379
|
+
Some(_) => Err(RegistryRollbackError::RestoreFailed),
|
|
380
|
+
None if std::fs::remove_file(&receipt.path).is_ok() => {
|
|
381
|
+
Ok(RegistryRollback::Restored)
|
|
382
|
+
}
|
|
383
|
+
None => Err(RegistryRollbackError::DeleteFailed),
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
fn remove_entry_if_matches(path: &Path, expected: &LeaderRegistryEntry) -> bool {
|
|
389
|
+
with_registry_lock(|_| {
|
|
390
|
+
let Ok(bytes) = std::fs::read(path) else {
|
|
391
|
+
return false;
|
|
392
|
+
};
|
|
393
|
+
let Ok(current) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
|
|
394
|
+
return false;
|
|
395
|
+
};
|
|
396
|
+
current == *expected && std::fs::remove_file(path).is_ok()
|
|
397
|
+
})
|
|
398
|
+
.unwrap_or(false)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fn replace_entry_bytes_locked(path: &Path, bytes: &[u8]) -> bool {
|
|
402
|
+
let tmp = path.with_extension(format!(
|
|
403
|
+
"rollback-{}-{}",
|
|
404
|
+
std::process::id(),
|
|
405
|
+
rand_suffix()
|
|
406
|
+
));
|
|
407
|
+
if std::fs::write(&tmp, bytes).is_err() {
|
|
408
|
+
let _ = std::fs::remove_file(&tmp);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
if std::fs::rename(&tmp, path).is_err() {
|
|
412
|
+
let _ = std::fs::remove_file(&tmp);
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
true
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
struct RegistryLock {
|
|
419
|
+
file: std::fs::File,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
impl RegistryLock {
|
|
423
|
+
fn acquire(dir: &Path) -> Option<Self> {
|
|
424
|
+
let path = dir.join(".registry.lock");
|
|
425
|
+
let file = std::fs::OpenOptions::new()
|
|
426
|
+
.create(true)
|
|
427
|
+
.read(true)
|
|
428
|
+
.write(true)
|
|
429
|
+
.truncate(false)
|
|
430
|
+
.open(path)
|
|
431
|
+
.ok()?;
|
|
432
|
+
let started = Instant::now();
|
|
433
|
+
loop {
|
|
434
|
+
match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
|
|
435
|
+
Ok(true) => return Some(Self { file }),
|
|
436
|
+
Ok(false) if started.elapsed() < Duration::from_secs(2) => {
|
|
437
|
+
std::thread::sleep(Duration::from_millis(10));
|
|
438
|
+
}
|
|
439
|
+
Ok(false) | Err(_) => return None,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
impl Drop for RegistryLock {
|
|
446
|
+
fn drop(&mut self) {
|
|
447
|
+
let _ = crate::platform::file_lock::unlock(&self.file);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
fn with_registry_lock<T>(f: impl FnOnce(&Path) -> T) -> Option<T> {
|
|
452
|
+
let dir = registry_dir()?;
|
|
453
|
+
std::fs::create_dir_all(&dir).ok()?;
|
|
454
|
+
let _lock = RegistryLock::acquire(&dir)?;
|
|
455
|
+
Some(f(&dir))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn with_registry_lock_result<T>(
|
|
459
|
+
f: impl FnOnce(&Path) -> Result<T, RegistryRollbackError>,
|
|
460
|
+
) -> Result<T, RegistryRollbackError> {
|
|
461
|
+
let dir = registry_dir().ok_or(RegistryRollbackError::LockUnavailable)?;
|
|
462
|
+
std::fs::create_dir_all(&dir).map_err(|_| RegistryRollbackError::LockUnavailable)?;
|
|
463
|
+
let _lock = RegistryLock::acquire(&dir).ok_or(RegistryRollbackError::LockUnavailable)?;
|
|
464
|
+
f(&dir)
|
|
309
465
|
}
|
|
310
466
|
|
|
311
467
|
fn rand_suffix() -> String {
|
|
@@ -324,14 +480,16 @@ fn rand_suffix() -> String {
|
|
|
324
480
|
/// success — the invariant is "no registry entry after unbind", not
|
|
325
481
|
/// "must have found something to delete".
|
|
326
482
|
pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
483
|
+
with_registry_lock(|dir| {
|
|
484
|
+
let hash = workspace_hash(workspace);
|
|
485
|
+
let path = dir.join(format!("{hash}__{team_key}.json"));
|
|
486
|
+
if path.exists() {
|
|
487
|
+
std::fs::remove_file(&path).ok()?;
|
|
488
|
+
return Some(path);
|
|
489
|
+
}
|
|
490
|
+
None
|
|
491
|
+
})
|
|
492
|
+
.flatten()
|
|
335
493
|
}
|
|
336
494
|
|
|
337
495
|
/// Read and deserialize every `*.json` file under the registry directory.
|
|
@@ -470,7 +628,7 @@ pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Optio
|
|
|
470
628
|
|| r == "team_not_alive"
|
|
471
629
|
})
|
|
472
630
|
{
|
|
473
|
-
let _ =
|
|
631
|
+
let _ = remove_entry_if_matches(&path, &entry);
|
|
474
632
|
continue;
|
|
475
633
|
}
|
|
476
634
|
out.push((entry, status, reason));
|
|
@@ -602,7 +602,18 @@ pub(crate) fn run_pi_catalog(
|
|
|
602
602
|
.stdout(Stdio::piped())
|
|
603
603
|
.stderr(Stdio::null())
|
|
604
604
|
.spawn()
|
|
605
|
-
.map_err(|
|
|
605
|
+
.map_err(|error| {
|
|
606
|
+
#[cfg(test)]
|
|
607
|
+
{
|
|
608
|
+
eprintln!(
|
|
609
|
+
"run_pi_catalog spawn failed kind={:?} raw_os_error={:?}",
|
|
610
|
+
error.kind(),
|
|
611
|
+
error.raw_os_error()
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
let _ = error;
|
|
615
|
+
"Pi executable is unavailable on PATH".to_string()
|
|
616
|
+
})?;
|
|
606
617
|
let stdout = child.stdout.take().ok_or_else(|| {
|
|
607
618
|
let _ = child.kill();
|
|
608
619
|
let _ = child.wait();
|