Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use super::{
HostBundleJournalEntryV1, HostBundleLifecycleOpV1, HostBundleManifestV1,
HostBundleReceiptArtifactV1, HostBundleRollbackBoundaryV1, HostBundleVerificationAdapterV1,
HostComponentSetJournalComponentV1, HostComponentSetJournalStateV1, HostComponentSetJournalV1,
HostComponentSetReceiptV1, HostComponentV1, HostKindV1,
HostComponentSetReceiptV1, HostComponentV1, HostKindV1, stock_host_kinds,
};

/// Public component-set lifecycle façade over the capability-rooted writer.
Expand All @@ -56,9 +56,19 @@ impl<'a> HostComponentSetTransactionV1<'a> {
&mut self,
registration: &mut R,
) -> Result<(), HostBundleError> {
let Some(journal) = self.writer.load_component_set_journal()? else {
for host in stock_host_kinds() {
if self.writer.load_journal_for(host)?.is_some() {
self.writer.recover_interrupted_operation(host)?;
}
}
return Ok(());
};
let host = journal.host;
self.writer.ensure_host_lock(host)?;
self.writer
.recover_component_set_operation(None, registration)?;
self.writer.recover_interrupted_operation()
.recover_component_set_operation(Some(host), registration)?;
self.writer.recover_interrupted_operation(host)
}

/// Recover only `host`'s pending component-set journal. Other hosts'
Expand All @@ -69,9 +79,10 @@ impl<'a> HostComponentSetTransactionV1<'a> {
host: HostKindV1,
registration: &mut R,
) -> Result<(), HostBundleError> {
self.writer.ensure_host_lock(host)?;
self.writer
.recover_component_set_operation(Some(host), registration)?;
self.writer.recover_interrupted_operation()
self.writer.recover_interrupted_operation(host)
}

pub fn preview<V: HostBundleVerificationAdapterV1, R: HostComponentSetRegistrationV1>(
Expand All @@ -84,7 +95,7 @@ impl<'a> HostComponentSetTransactionV1<'a> {
// Only this host's own pending journal blocks the preview. A wedged
// transaction for an unrelated host mutates a disjoint path space and
// is not a reason to refuse work here.
if self.writer.load_journal()?.is_some()
if self.writer.load_journal_for(component_set.host)?.is_some()
|| self
.writer
.load_component_set_journal_for(component_set.host)?
Expand Down Expand Up @@ -238,7 +249,8 @@ impl HostBundleWriterV1 {
registration: &mut R,
) -> Result<HostComponentSetReceiptV1, HostBundleError> {
validate_component_set_request(component_set, request)?;
if self.load_journal()?.is_some() {
self.ensure_host_lock(component_set.host)?;
if self.load_journal_for(component_set.host)?.is_some() {
return Err(host_bundle_recovery_required!());
}
// Never clobber this host's own outstanding journal: it is the only
Expand Down
21 changes: 19 additions & 2 deletions crates/tracedecay-agent-hosts/src/agents/host_bundle/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ use super::{
};

pub(super) const HOST_BUNDLE_CONTROL_DIR: &str = ".tracedecay-host-bundle-v1";
/// Legacy shared single-component journal. One file per lifecycle root meant
/// recovering host Y rolled back host X, and a wedged journal blocked every
/// other host. Journals are host-scoped now; this name is still read (and
/// retired) so a journal left by an older binary is recovered rather than
/// orphaned.
pub(super) const HOST_BUNDLE_JOURNAL_FILE: &str = "journal.v1.json";
/// Legacy shared component-set journal name. One journal per lifecycle root
/// meant an interrupted transaction for any host blocked every other host.
Expand All @@ -33,6 +38,9 @@ pub(super) const HOST_COMPONENT_SET_STAGE_DIR: &str = "component-set-staging";
/// Set-aside directory for journals an operator explicitly abandoned with
/// `tracedecay host-bundle recover --quarantine --yes`. Backups stay in place.
pub(super) const HOST_BUNDLE_QUARANTINE_DIR: &str = "quarantine";
/// Retired lifecycle-root lock. Hosts do not share a write target, so each
/// host owns `writer.{slug}.v1.lock`. This name is not acquired; a new binary
/// must not recreate it or independent hosts serialize again.
pub(super) const HOST_BUNDLE_LOCK_FILE: &str = "writer.v1.lock";
pub(super) const MAX_CONTROL_FILE_BYTES: usize = 256 * 1024;

Expand Down Expand Up @@ -561,12 +569,21 @@ pub(super) fn receipt_file(host: HostKindV1, component: HostComponentV1) -> Stri
/// reason to refuse Y. `first_party_host_artifact_prefixes_are_disjoint`
/// pins that premise as a test, so a future host that violates it fails the
/// suite rather than silently widening the blast radius. The receipt namespace
/// is already host-scoped (`receipt_file`), and the single writer lock still
/// serializes all mutation within a lifecycle root.
/// is already host-scoped (`receipt_file`). The writer lock is host-scoped
/// too (`writer_lock_file`): one host's in-flight mutation is a real
/// invariant, a second host's is not.
pub(super) fn component_set_journal_file(host: HostKindV1) -> String {
format!("component-set-journal.{}.v1.json", host.descriptor().slug())
}

pub(super) fn journal_file(host: HostKindV1) -> String {
format!("journal.{}.v1.json", host.descriptor().slug())
}

pub(super) fn writer_lock_file(host: HostKindV1) -> String {
format!("writer.{}.v1.lock", host.descriptor().slug())
}

pub(super) fn component_set_receipt_file(operation_id: [u8; 16]) -> String {
format!(
"component-set-receipt.{}.v1.json",
Expand Down
72 changes: 39 additions & 33 deletions crates/tracedecay-agent-hosts/src/agents/host_bundle/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use tracedecay_host_integration::host_bundle_storage_failure;

use super::control::{
HOST_BUNDLE_CONTROL_DIR, HOST_BUNDLE_JOURNAL_FILE, HOST_COMPONENT_SET_JOURNAL_FILE,
MAX_CONTROL_FILE_BYTES, component_set_journal_file, component_slug, receipt_file,
MAX_CONTROL_FILE_BYTES, component_set_journal_file, component_slug, journal_file, receipt_file,
receipt_identity_from_file_name, validate_component_set_journal, validate_journal,
validate_receipt,
};
Expand Down Expand Up @@ -365,44 +365,50 @@ pub fn inspect_installed_host_bundle_components_at(
repair_action: component_repair_action,
});
}
let journal_path = control_root.join(HOST_BUNDLE_JOURNAL_FILE);
if journal_path.exists() {
let journal = fs::read(&journal_path)
.ok()
.filter(|bytes| !bytes.is_empty() && bytes.len() <= MAX_CONTROL_FILE_BYTES)
.and_then(|bytes| serde_json::from_slice::<HostBundleJournalV1>(&bytes).ok())
.filter(|journal| validate_journal(journal).is_ok());
match journal {
Some(journal) => {
if let Some(component) = components.iter_mut().find(|component| {
component.host == Some(journal.host)
&& component.component == Some(journal.component)
}) {
component.state = HostBundleComponentDoctorStateV1::Repairable;
component.repair_action = repair_action(
journal.host,
journal.component,
HostBundleComponentDoctorStateV1::Repairable,
HostBundleRegistrationStateV1::Current,
);
} else {
components.push(HostBundleComponentDoctorResultV1 {
receipt_path: journal_path.clone(),
host: Some(journal.host),
component: Some(journal.component),
state: HostBundleComponentDoctorStateV1::Repairable,
registration: None,
artifacts: Vec::new(),
repair_action: repair_action(
// Single-component journals are host-scoped; the legacy shared name is
// still inspected so a journal left by an older binary stays visible.
let journal_paths = std::iter::once(HOST_BUNDLE_JOURNAL_FILE.to_string())
.chain(stock_host_kinds().into_iter().map(journal_file))
.map(|file| control_root.join(file));
for journal_path in journal_paths {
if journal_path.exists() {
let journal = fs::read(&journal_path)
.ok()
.filter(|bytes| !bytes.is_empty() && bytes.len() <= MAX_CONTROL_FILE_BYTES)
.and_then(|bytes| serde_json::from_slice::<HostBundleJournalV1>(&bytes).ok())
.filter(|journal| validate_journal(journal).is_ok());
match journal {
Some(journal) => {
if let Some(component) = components.iter_mut().find(|component| {
component.host == Some(journal.host)
&& component.component == Some(journal.component)
}) {
component.state = HostBundleComponentDoctorStateV1::Repairable;
component.repair_action = repair_action(
journal.host,
journal.component,
HostBundleComponentDoctorStateV1::Repairable,
HostBundleRegistrationStateV1::Current,
),
});
);
} else {
components.push(HostBundleComponentDoctorResultV1 {
receipt_path: journal_path.clone(),
host: Some(journal.host),
component: Some(journal.component),
state: HostBundleComponentDoctorStateV1::Repairable,
registration: None,
artifacts: Vec::new(),
repair_action: repair_action(
journal.host,
journal.component,
HostBundleComponentDoctorStateV1::Repairable,
HostBundleRegistrationStateV1::Current,
),
});
}
}
None => components.push(corrupt_component_result(journal_path, None, None)),
}
None => components.push(corrupt_component_result(journal_path, None, None)),
}
}
// Component-set journals are host-scoped; the legacy shared name is still
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ where
contents: &[HostBundleArtifactContentV1],
) -> Result<HostBundleInstallReceiptV1, HostBundleError> {
self.verifier.verify_manifest(manifest)?;
self.storage.recover_lifecycle()?;
// execute_lifecycle recovers this manifest's host. Recovering every
// host here would roll back an unrelated host's journal.
self.storage
.execute_lifecycle(manifest, request, contents, &self.verifier)
}
Expand Down
163 changes: 159 additions & 4 deletions crates/tracedecay-agent-hosts/src/agents/host_bundle/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ use sha2::{Digest, Sha256};
use tracedecay_host_integration::host_bundle_storage_failure;

use super::control::{
HOST_BUNDLE_CONTROL_DIR, HOST_COMPONENT_SET_JOURNAL_FILE, component_set_journal_file,
expected_ownership_marker, host_bundle_backup_receipt_file, host_bundle_restore_receipt_file,
receipt_file, validate_component_set_journal,
HOST_BUNDLE_CONTROL_DIR, HOST_BUNDLE_JOURNAL_FILE, HOST_BUNDLE_LOCK_FILE,
HOST_COMPONENT_SET_JOURNAL_FILE, component_set_journal_file, expected_ownership_marker,
host_bundle_backup_receipt_file, host_bundle_restore_receipt_file, journal_file, receipt_file,
validate_component_set_journal, writer_lock_file,
};
use super::doctor::doctor_artifact_state;
use super::planner::plan_artifact_action;
Expand Down Expand Up @@ -896,6 +897,160 @@ fn host_scoped_component_set(host: HostKindV1, slug: &str, tag: &[u8]) -> HostCo
}
}

/// Defect: one `writer.v1.lock` and one `journal.v1.json` still serialized
/// every host after component-set journals were split. A writer that has
/// already admitted OpenCode must not stop Codex, and must not roll back
/// OpenCode's legacy single-component journal.
#[test]
fn a_host_lock_does_not_exclude_an_unrelated_host() {
let root = tempfile::tempdir().unwrap();
let opencode = host_scoped_component_set(HostKindV1::OpenCode, "opencode", b"v1");
let opencode_request =
component_set_request(HostKindV1::OpenCode, HostBundleLifecycleOpV1::Install, 61);
let mut holder = HostBundleWriterV1::open(root.path()).unwrap();
HostComponentSetTransactionV1::new(&mut holder)
.execute(
&opencode,
&opencode_request,
&ComponentSetVerifier::from_set(&opencode),
&mut ArtifactOnlyTestRegistration,
)
.expect("opencode install admits the host lock");
assert!(
root.path()
.join(HOST_BUNDLE_CONTROL_DIR)
.join(writer_lock_file(HostKindV1::OpenCode))
.is_file()
);
assert!(
!root
.path()
.join(HOST_BUNDLE_CONTROL_DIR)
.join(HOST_BUNDLE_LOCK_FILE)
.exists(),
"the retired lifecycle-root lock must not be recreated"
);

let codex = host_scoped_component_set(HostKindV1::Codex, "codex", b"v1");
let codex_request =
component_set_request(HostKindV1::Codex, HostBundleLifecycleOpV1::Install, 62);
let mut other = HostBundleWriterV1::open(root.path()).unwrap();
HostComponentSetTransactionV1::new(&mut other)
.execute(
&codex,
&codex_request,
&ComponentSetVerifier::from_set(&codex),
&mut ArtifactOnlyTestRegistration,
)
.expect("codex must not wait on opencode's writer lock");
assert_eq!(
fs::read(root.path().join("codex/core.json")).unwrap(),
b"v1"
);

let contended = host_scoped_component_set(HostKindV1::OpenCode, "opencode", b"v2");
let contended_request =
component_set_request(HostKindV1::OpenCode, HostBundleLifecycleOpV1::Repair, 63);
assert!(
matches!(
HostComponentSetTransactionV1::new(&mut other)
.execute(
&contended,
&contended_request,
&ComponentSetVerifier::from_set(&contended),
&mut ArtifactOnlyTestRegistration,
)
.err(),
Some(HostBundleError::RecoveryRequired(_))
),
"the same host still has exactly one writer"
);
}

/// Defect: `journal.v1.json` was still one file for every host. Recovering or
/// installing Codex must not roll back an OpenCode journal left by an older
/// binary. OpenCode's own recovery retires that legacy name.
#[test]
fn a_legacy_single_component_journal_is_attributed_to_its_own_host() {
let root = tempfile::tempdir().unwrap();
let artifact = root.path().join("opencode/core.json");
fs::create_dir_all(artifact.parent().unwrap()).unwrap();
fs::write(&artifact, b"opencode-bytes").unwrap();
let digest: [u8; 32] = Sha256::digest(b"opencode-bytes").into();
let journal = HostBundleJournalV1 {
schema_version: HOST_BUNDLE_RECEIPT_SCHEMA_VERSION,
operation_id: [71; 16],
host: HostKindV1::OpenCode,
component: HostComponentV1::Core,
operation: HostBundleLifecycleOpV1::Install,
manifest_digest: digest,
state: HostBundleJournalStateV1::Prepared,
previous_receipt: None,
entries: vec![HostBundleJournalEntryV1 {
relative_path: "opencode/core.json".to_string(),
backup_name: None,
backup_created: false,
wrote_new: true,
installed_digest: Some(digest),
}],
};
let control = root.path().join(HOST_BUNDLE_CONTROL_DIR);
fs::create_dir_all(&control).unwrap();
fs::write(
control.join(HOST_BUNDLE_JOURNAL_FILE),
serde_json::to_vec(&journal).unwrap(),
)
.unwrap();

let codex = host_scoped_component_set(HostKindV1::Codex, "codex", b"v1");
let codex_request =
component_set_request(HostKindV1::Codex, HostBundleLifecycleOpV1::Install, 72);
let mut writer = HostBundleWriterV1::open(root.path()).unwrap();
HostComponentSetTransactionV1::new(&mut writer)
.execute(
&codex,
&codex_request,
&ComponentSetVerifier::from_set(&codex),
&mut ArtifactOnlyTestRegistration,
)
.expect("codex install must not refuse on opencode's legacy journal");
assert_eq!(fs::read(&artifact).unwrap(), b"opencode-bytes");
assert!(
control.join(HOST_BUNDLE_JOURNAL_FILE).is_file(),
"codex must not retire opencode's legacy journal"
);

let doctor = inspect_installed_host_bundle_components_at(
root.path(),
root.path(),
&CurrentRegistration,
crate::agents::TEST_GENERATOR_COMMIT,
)
.unwrap();
assert!(
doctor.components.iter().any(|component| {
component.host == Some(HostKindV1::OpenCode)
&& component.component == Some(HostComponentV1::Core)
&& component.state == HostBundleComponentDoctorStateV1::Repairable
}),
"doctor still reports the legacy single-component journal"
);

HostComponentSetTransactionV1::new(&mut writer)
.recover_host(HostKindV1::OpenCode, &mut ArtifactOnlyTestRegistration)
.expect("opencode recovery owns the legacy journal");
assert!(
!artifact.exists(),
"opencode recovery rolls its own interrupted install back"
);
assert!(!control.join(HOST_BUNDLE_JOURNAL_FILE).exists());
assert!(!control.join(journal_file(HostKindV1::OpenCode)).exists());
assert_eq!(
fs::read(root.path().join("codex/core.json")).unwrap(),
b"v1"
);
}

/// Defect: one shared journal per lifecycle root meant a wedged opencode
/// repair blocked codex, cursor, cline, roo-code, kilo, kiro, and kimi in
/// the same `tracedecay reinstall`. Journals are host-scoped now, and the
Expand Down Expand Up @@ -1352,7 +1507,7 @@ fn feedback_switch_apply_restore_and_aggregate_receipt_share_writer_recovery() {
&[],
)
.unwrap();
let writer = switch.into_lifecycle().into_storage();
let mut writer = switch.into_lifecycle().into_storage();
let aggregate = writer
.publish_feedback_component_set_receipt(&previous, &restore.restore_receipt)
.unwrap();
Expand Down
Loading
Loading