fix(engine): recover workflow, state path and catalog safety guards

Validate workflow result availability in execution order, isolating parallel
siblings and conditional branches. Refuse implicit relative StateStore
locations when no absolute home resolves. Treat provider catalog timestamps
more than five minutes ahead as stale while retaining explicit fallback rows.

Recovers current-owner guarantees from 737eb3d5847, f1176667c4c and
77ed7363c74 without restoring retired runtime or cache implementations.

macOS hermetic cached checks: workflow lib 238 passed, 0 failed;
config catalog focused 3 passed, 0 failed; state focused 2 passed, 0 failed.
Each new behavioral regression failed 0 passed/1 failed without its guard;
restored final tree selected 1/0 per crate (3 total). First workflow negative
control was rejected by -D warnings for its unused helper, then corrected
by removing that helper and rerun to the expected assertion failure.
Preflight 8 steps passed; version drift and ordinary whitespace passed.
No hosted CI, provider, installed-artifact or release qualification claimed.
This commit is contained in:
CodeWhale Bot committed 2026-11-09 19:31:57 -08:00
1 parent fd9ca79d70
commit 2e90ca43f8
6 files changed
+207 -3

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Recovery safeguards:** workflows reject results that cannot yet exist, implicit session storage refuses an unresolved home, and future-dated provider catalogs cannot stay fresh indefinitely.
- **Permissions:** saved permission choices, including Ask on existing and new installs, survive upgrades and restarts; managed approval policies keep their stricter startup posture when using the new permission dial, and Settings shows Accept Edits as the unconfigured default.
## [0.10.3] - 2026-11-08
+1 -1
View File
@@ -1039,7 +1039,7 @@ impl CachedProviderCatalog {
/// A `ttl_secs` of zero means "always stale" (never serve as fresh).
#[must_use]
pub fn is_stale(&self, now_unix: u64) -> bool {
self.age_secs(now_unix) >= self.ttl_secs
self.fetched_at.saturating_sub(now_unix) > 300 || self.age_secs(now_unix) >= self.ttl_secs
}
/// Whether this entry may contribute live offerings at `now_unix`.
+31
View File
@@ -587,6 +587,37 @@ fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
assert_eq!(cache.get("acme", &fp).unwrap().offerings.len(), 1);
}
#[test]
fn future_catalog_timestamps_cannot_extend_freshness() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 1_300,
offerings: vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
..Default::default()
}],
},
100,
);
assert_eq!(cache.fresh_offerings("acme", &fp, 1_000).len(), 1);
assert!(cache.fresh_offerings("acme", &fp, 999).is_empty());
assert!(matches!(
cache.status("acme", &fp, 999),
CatalogStatus::Stale { .. }
));
assert_eq!(cache.get("acme", &fp).unwrap().offerings.len(), 1);
let entry = cache.entries.values_mut().next().unwrap();
entry.fetched_at = u64::MAX;
assert!(!entry.is_fresh(1_000));
entry.fetched_at = u64::MAX - 1;
assert!(entry.is_fresh(u64::MAX - 2));
}
#[test]
fn ttl_zero_is_always_stale() {
let fp = base_url_fingerprint("https://api.example.com");
+52 -1
View File
@@ -264,7 +264,17 @@ impl StateStore {
/// `~/.deepseek/state.db` as a legacy fallback) is used.
/// The database schema is created automatically if it does not exist.
pub fn open(path: Option<PathBuf>) -> Result<Self> {
let db_path = path.unwrap_or_else(default_state_db_path);
let db_path = match path {
Some(path) => path,
None => {
let default = default_state_db_path();
anyhow::ensure!(
default.is_absolute(),
"could not resolve an absolute home directory for the state database; set CODEWHALE_HOME to an absolute path"
);
default
}
};
let session_index_path = db_path
.parent()
.unwrap_or_else(|| Path::new("."))
@@ -2784,6 +2794,47 @@ mod tests {
assert_eq!(default_state_db_path(), dir.join("state.db"));
}
#[test]
fn implicit_state_database_refuses_unresolved_home() {
const PROBE: &str = "CODEWHALE_STATE_DEFAULT_PATH_PROBE";
if std::env::var_os(PROBE).is_some() {
let error =
StateStore::open(None).expect_err("implicit relative state must be refused");
assert!(error.to_string().contains("absolute home directory"));
assert!(!Path::new(".codewhale").exists());
StateStore::open(Some(PathBuf::from("explicit/state.db")))
.expect("an explicitly chosen relative path remains supported");
return;
}
let probe = std::env::temp_dir().join(format!(
"cw-state-default-probe-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&probe).unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"tests::implicit_state_database_refuses_unresolved_home",
])
.env(PROBE, "1")
.env_remove("CODEWHALE_HOME")
.env("HOME", "relative-home")
.current_dir(&probe)
.output()
.unwrap();
fs::remove_dir_all(&probe).unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn load_checkpoint_propagates_invalid_state_json() {
let store = temp_state_store("checkpoint-parse-error");
+2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Recovery safeguards:** workflows reject results that cannot yet exist, implicit session storage refuses an unresolved home, and future-dated provider catalogs cannot stay fresh indefinitely.
- **Permissions:** saved permission choices, including Ask on existing and new installs, survive upgrades and restarts; managed approval policies keep their stricter startup posture when using the new permission dial, and Settings shows Accept Edits as the unconfigured default.
## [0.10.3] - 2026-11-08
+119 -1
View File
@@ -1727,6 +1727,12 @@ pub enum WorkflowExecutionError {
field: &'static str,
reference: String,
},
#[error("workflow node `{node}` reads {field} `{reference}` before its result is available")]
UnavailableNodeReference {
node: String,
field: &'static str,
reference: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1896,7 +1902,8 @@ pub(crate) fn validate_workflow_nodes(
}
let mut seen = BTreeSet::new();
validate_workflow_nodes_inner(nodes, &mut seen)?;
validate_workflow_references(nodes, &seen)
validate_workflow_references(nodes, &seen)?;
validate_reference_order(nodes, &mut BTreeSet::new())
}
pub(crate) fn validate_workflow_node_shapes(
@@ -2016,6 +2023,57 @@ fn validate_leaf_role(leaf: &str, role: &str) -> Result<(), WorkflowExecutionErr
Ok(())
}
fn validate_reference_order(
nodes: &[WorkflowNode],
finished: &mut BTreeSet<String>,
) -> Result<(), WorkflowExecutionError> {
for node in nodes {
let (field, references): (&'static str, &[String]) = match node {
WorkflowNode::Leaf(spec) => ("depends_on_results", &spec.depends_on_results),
WorkflowNode::Reduce(spec) => ("inputs", &spec.inputs),
WorkflowNode::TeacherReview(spec) => ("candidates", &spec.candidates),
_ => ("", &[]),
};
let id = node_id(node);
if let Some(reference) = references
.iter()
.find(|reference| !finished.contains(*reference))
{
return Err(WorkflowExecutionError::UnavailableNodeReference {
node: id,
field,
reference: reference.clone(),
});
}
match node {
WorkflowNode::BranchSet(spec) if spec.parallel => {
let before = finished.clone();
for child in &spec.children {
let mut branch = before.clone();
validate_reference_order(std::slice::from_ref(child), &mut branch)?;
finished.extend(branch);
}
}
WorkflowNode::BranchSet(spec) => validate_reference_order(&spec.children, finished)?,
WorkflowNode::Sequence(spec) => validate_reference_order(&spec.children, finished)?,
WorkflowNode::LoopUntil(spec) => validate_reference_order(&spec.children, finished)?,
WorkflowNode::Cond(spec) => {
let mut then_arm = finished.clone();
validate_reference_order(&spec.then_nodes, &mut then_arm)?;
let mut else_arm = finished.clone();
validate_reference_order(&spec.else_nodes, &mut else_arm)?;
finished.extend(then_arm.intersection(&else_arm).cloned());
}
WorkflowNode::Leaf(_)
| WorkflowNode::Reduce(_)
| WorkflowNode::TeacherReview(_)
| WorkflowNode::Expand(_) => {}
}
finished.insert(id);
}
Ok(())
}
fn validate_known_references(
node: &str,
field: &'static str,
@@ -3854,6 +3912,66 @@ mod tests {
);
}
#[test]
fn workflow_spec_rejects_unavailable_result_references() {
let reduce = |id: &str, inputs: &[&str]| {
WorkflowNode::Reduce(ReduceSpec {
id: id.to_string(),
inputs: inputs.iter().map(|input| input.to_string()).collect(),
prompt: "combine".to_string(),
model_policy: ModelPolicy::default(),
})
};
let parallel = |children: Vec<WorkflowNode>| {
WorkflowNode::BranchSet(BranchSpec {
id: "fan".to_string(),
description: None,
parallel: true,
budget: BudgetSpec::default(),
permissions: PermissionSpec::default(),
model_policy: ModelPolicy::default(),
children,
})
};
let conditional = || {
WorkflowNode::Cond(CondSpec {
id: "choice".to_string(),
condition: "choose".to_string(),
then_nodes: vec![leaf_node("yes")],
else_nodes: vec![leaf_node("no")],
})
};
for (nodes, reference) in [
(vec![reduce("sum", &["later"]), leaf_node("later")], "later"),
(vec![reduce("sum", &["sum"])], "sum"),
(
vec![parallel(vec![leaf_node("a"), reduce("sum", &["a"])])],
"a",
),
(vec![parallel(vec![reduce("sum", &["fan"])])], "fan"),
(vec![conditional(), reduce("sum", &["yes"])], "yes"),
(vec![conditional(), reduce("sum", &["no"])], "no"),
] {
assert!(
matches!(
workflow_spec(nodes).validate_for_fleet(),
Err(WorkflowFleetLimitError::InvalidWorkflow {
source: WorkflowExecutionError::UnavailableNodeReference { reference: found, .. }
}) if found == reference
),
"{reference} must already have completed"
);
}
workflow_spec(vec![
parallel(vec![leaf_node("a"), leaf_node("b")]),
reduce("sum", &["a", "b", "fan"]),
conditional(),
reduce("after_choice", &["sum", "choice"]),
])
.validate_for_fleet()
.expect("completed results remain available");
}
#[test]
fn workflow_spec_rejects_unknown_reduce_input() {
let workflow = workflow_spec(vec![