mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 01:51:32 -10:00
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:
1 parent
fd9ca79d70
commit
2e90ca43f8
6 files changed
+207
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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![
|
||||
|
||||
Reference in new issue
Block a user