Skip to content

Commit d691d71

Browse files
fix: reject conflicting shared workspace renders
agent-session-id: a078daee-6f98-4916-91a8-d21291407789 agent-tool: Codex CLI agent-tool-version: 0.145.0 agent-model: unknown agent-runtime-profile: /nix/store/mnx8agbdq3wiyb6vz63lhgscgazkrn98-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/5r69m9k2llmri3na81518zx0a7y0d3cn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@0fb7e03
1 parent f96f925 commit d691d71

7 files changed

Lines changed: 354 additions & 5 deletions

File tree

docs/vrs/spec.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler
7474
ordinary declaration space; only `.git` and `.st2` directories at any depth,
7575
the catalog root's `pty` child, and a declaration parent's `resources`,
7676
`archive`, and `inbox` children are excluded.
77+
A resolved workspace-relative render destination has one coherent desired
78+
state across the active local fleet: byte-equivalent idempotent claims may
79+
share it, while incompatible
80+
claims fail every conflicting owner before the first workspace write.
81+
Targeted reconciliation checks the selected owner against the full fleet, so
82+
selection cannot bypass this ownership boundary.
7783
- **R04:** Each machine schedules and reconciles only its pinned work. The st2
7884
loop is deterministic; exactly one declared root agent provides intelligent
7985
host-local supervision, bounded recovery, and escalation. Filesystem reads

src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,7 @@ fn up(
17851785

17861786
if materialize_only {
17871787
let mut found = discover(&catalog_root);
1788+
let ownership_specs = found.specs.clone();
17881789
if let Some(selector) = task.as_deref() {
17891790
let (owner, _, _) = st2::reconcile::resolve_task(&found.specs, selector, &this_host)?;
17901791
let owner_identity = owner.identity.clone();
@@ -1809,7 +1810,12 @@ fn up(
18091810
"verifying explicitly installed lifecycle hooks before Codex materialization",
18101811
)?;
18111812
}
1812-
let report = st2::materialize::materialize_catalog(&catalog_root, &found.specs, &this_host);
1813+
let report = st2::materialize::materialize_catalog_against(
1814+
&catalog_root,
1815+
&found.specs,
1816+
&ownership_specs,
1817+
&this_host,
1818+
);
18131819
for item in &report.materialized {
18141820
println!("{item}");
18151821
}

src/materialize.rs

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! The runner stays harness-agnostic: these are generic, ordered file operations. Content operations
44
//! gate an agent's boot; `git-exclude` is advisory and can never prevent a launch.
55
6-
use std::collections::{BTreeMap, HashSet};
6+
use std::collections::{BTreeMap, BTreeSet, HashSet};
77
use std::fs;
88
use std::path::{Component, Path, PathBuf};
99
use std::process::Command;
@@ -452,6 +452,143 @@ enum PreparedOp {
452452
},
453453
}
454454

455+
#[derive(Debug, Clone, PartialEq)]
456+
enum RenderClaim {
457+
Replace(Vec<u8>),
458+
JsonUpsert(serde_json::Value),
459+
EnsureLine(String),
460+
}
461+
462+
#[derive(Debug)]
463+
pub struct RenderOwnershipConflict {
464+
pub destination: PathBuf,
465+
pub owners: BTreeSet<String>,
466+
}
467+
468+
impl RenderOwnershipConflict {
469+
fn error(&self) -> String {
470+
format!(
471+
"conflicting render ownership for '{}': active agents {} declare incompatible content for one shared workspace target",
472+
self.destination.display(),
473+
self.owners.iter().cloned().collect::<Vec<_>>().join(", ")
474+
)
475+
}
476+
}
477+
478+
fn claims_for_agent(
479+
root: &Path,
480+
spec: &AgentSpec,
481+
this_host: &str,
482+
) -> Result<BTreeMap<PathBuf, Vec<RenderClaim>>> {
483+
let plan = parse_plan(spec)?;
484+
if plan.ops.is_empty() {
485+
return Ok(BTreeMap::new());
486+
}
487+
let workspace_raw = spec
488+
.workspace
489+
.as_deref()
490+
.with_context(|| format!("agent '{}' has render{{}} but no workspace", spec.identity))?;
491+
let env = render_env(root, spec, this_host);
492+
let workspace = PathBuf::from(expand(workspace_raw, &env));
493+
let workspace = workspace
494+
.canonicalize()
495+
.with_context(|| format!("canonicalizing workspace {}", workspace.display()))?;
496+
let spec_dir = spec.path.parent().unwrap_or(root);
497+
let mut claims = BTreeMap::<PathBuf, Vec<RenderClaim>>::new();
498+
for op in plan.ops {
499+
let (destination, claim) = match op {
500+
RenderOp::Copy {
501+
source: raw_source,
502+
destination: raw_destination,
503+
} => {
504+
let source = source(root, spec_dir, &raw_source, &env)?;
505+
let bytes = fs::read(&source)
506+
.with_context(|| format!("reading copy source {}", source.display()))?;
507+
(
508+
destination(&workspace, &raw_destination, &env)?,
509+
RenderClaim::Replace(bytes),
510+
)
511+
}
512+
RenderOp::File {
513+
destination: raw_destination,
514+
content,
515+
} => (
516+
destination(&workspace, &raw_destination, &env)?,
517+
RenderClaim::Replace(expand(&content, &env).into_bytes()),
518+
),
519+
RenderOp::JsonUpsert {
520+
destination: raw_destination,
521+
content,
522+
} => {
523+
let patch = serde_json::from_str(&expand(&content, &env)).with_context(|| {
524+
format!(
525+
"expanded json-upsert for '{}' is not valid JSON",
526+
spec.identity
527+
)
528+
})?;
529+
(
530+
destination(&workspace, &raw_destination, &env)?,
531+
RenderClaim::JsonUpsert(patch),
532+
)
533+
}
534+
RenderOp::EnsureLine {
535+
destination: raw_destination,
536+
line,
537+
} => (
538+
destination(&workspace, &raw_destination, &env)?,
539+
RenderClaim::EnsureLine(expand(&line, &env)),
540+
),
541+
// Every git-exclude is additive by contract and resolves through Git's own shared
542+
// metadata path rather than a declared workspace-relative render destination.
543+
RenderOp::GitExclude { .. } => continue,
544+
};
545+
claims.entry(destination).or_default().push(claim);
546+
}
547+
Ok(claims)
548+
}
549+
550+
/// Find active local agents that declare incompatible desired content for one resolved workspace
551+
/// destination. Equivalent idempotent plans may share a target; differing plans have no implicit
552+
/// last-writer-wins semantics.
553+
pub fn render_ownership_conflicts(
554+
root: &Path,
555+
specs: &[AgentSpec],
556+
this_host: &str,
557+
) -> Vec<RenderOwnershipConflict> {
558+
let mut by_destination = BTreeMap::<PathBuf, BTreeMap<String, Vec<RenderClaim>>>::new();
559+
for spec in specs {
560+
if spec.retired || spec.resolved_host(this_host) != this_host {
561+
continue;
562+
}
563+
let Ok(claims) = claims_for_agent(root, spec, this_host) else {
564+
// The normal per-agent materialization path reports malformed plans and unavailable
565+
// inputs. Ownership analysis only compares claims it can resolve without writing.
566+
continue;
567+
};
568+
let owner = spec.bus_id(this_host);
569+
for (destination, plan) in claims {
570+
by_destination
571+
.entry(destination)
572+
.or_default()
573+
.insert(owner.clone(), plan);
574+
}
575+
}
576+
577+
by_destination
578+
.into_iter()
579+
.filter_map(|(destination, claims)| {
580+
let mut plans = claims.values();
581+
let first = plans.next()?;
582+
plans
583+
.any(|plan| plan != first)
584+
.then(|| RenderOwnershipConflict {
585+
destination,
586+
owners: claims.into_keys().collect(),
587+
})
588+
})
589+
.collect()
590+
}
591+
455592
/// Execute one agent's render plan in declaration order.
456593
pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result<Vec<String>> {
457594
let plan = parse_plan(spec)?;
@@ -699,12 +836,45 @@ pub fn validate_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Result<
699836

700837
/// Materialize every active agent assigned to `this_host`.
701838
pub fn materialize_catalog(root: &Path, specs: &[AgentSpec], this_host: &str) -> MaterializeReport {
839+
materialize_catalog_against(root, specs, specs, this_host)
840+
}
841+
842+
/// Materialize `selected_specs` after checking their workspace target ownership against the complete
843+
/// active fleet. This preserves shortest-path selection while preventing a selected owner from
844+
/// bypassing a collision declared by an unselected sibling.
845+
pub fn materialize_catalog_against(
846+
root: &Path,
847+
selected_specs: &[AgentSpec],
848+
ownership_specs: &[AgentSpec],
849+
this_host: &str,
850+
) -> MaterializeReport {
702851
let mut report = MaterializeReport::default();
703-
for spec in specs {
852+
let selected_ids = selected_specs
853+
.iter()
854+
.map(|spec| spec.bus_id(this_host))
855+
.collect::<HashSet<_>>();
856+
for conflict in render_ownership_conflicts(root, ownership_specs, this_host) {
857+
let affected = conflict
858+
.owners
859+
.iter()
860+
.filter(|owner| selected_ids.contains(*owner))
861+
.cloned()
862+
.collect::<Vec<_>>();
863+
if affected.is_empty() {
864+
continue;
865+
}
866+
report.failed_agents.extend(affected);
867+
report.errors.push(conflict.error());
868+
}
869+
870+
for spec in selected_specs {
704871
if spec.retired || spec.resolved_host(this_host) != this_host {
705872
continue;
706873
}
707874
let bus_id = spec.bus_id(this_host);
875+
if report.failed_agents.contains(&bus_id) {
876+
continue;
877+
}
708878
match materialize_agent(root, spec, this_host) {
709879
Ok(notes) => {
710880
for note in notes {

src/run.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,9 +998,10 @@ pub fn up_once_selected(
998998
.push(format!("verify lifecycle hooks: {error}"));
999999
return Ok(report);
10001000
}
1001-
let materialized = crate::materialize::materialize_catalog(
1001+
let materialized = crate::materialize::materialize_catalog_against(
10021002
catalog_root,
10031003
std::slice::from_ref(&owner),
1004+
&found.specs,
10041005
this_host,
10051006
);
10061007
report.warnings.extend(materialized.warnings);

src/validate.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,21 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report {
298298
}
299299
}
300300

301+
if let Some(host) = this_host {
302+
for conflict in crate::materialize::render_ownership_conflicts(root, &d.specs, host) {
303+
issues.push(Issue::error(
304+
"render-owner-conflict",
305+
".".to_string(),
306+
None,
307+
format!(
308+
"conflicting render ownership for '{}': active agents {} declare incompatible content for one shared workspace target",
309+
conflict.destination.display(),
310+
conflict.owners.iter().cloned().collect::<Vec<_>>().join(", ")
311+
),
312+
));
313+
}
314+
}
315+
301316
Report {
302317
issues,
303318
agents: d.specs.len(),

0 commit comments

Comments
 (0)