Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ workflow copies that section onto the GitHub Release page and refuses to
publish when it is missing, empty, or still says Unreleased. Write it in the
version bump pull request by renaming `## Unreleased` to the version.

## 0.19.9 - 2026-10-07

xcb keeps unattended schedules running without you.

- `xcb schedules edit <id> --dismiss-uncertain true` lets a schedule dismiss uncertain work in its directory once no worker is still running, the same way `xcb backlog dismiss` does. Nothing is retried, and the next wake-up goes ahead instead of waiting for you.
- After an xcb upgrade, a newly adopted provider build, or 30 days, xcb re-runs the native checks you already passed for providers a directory grant names. Granted directories keep running instead of quietly finding no account.

## 0.19.8 - 2026-10-06

xcb lets a herd program move on after you dismiss one of its tasks.
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/xcb-platform", "crates/xcb-core", "crates/xcb-runtime", "crat
resolver = "3"

[workspace.package]
version = "0.19.8"
version = "0.19.9"
edition = "2024"
rust-version = "1.97.1"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions crates/xcb-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ serde = "=1.0.229"
serde_json = "=1.0.151"
tokio = { version = "=1.47.1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
unicode-width = "=0.2.2"
xcb-core = { version = "0.19.8", path = "../xcb-core" }
xcb-runtime = { version = "0.19.8", path = "../xcb-runtime" }
xcb-core = { version = "0.19.9", path = "../xcb-core" }
xcb-runtime = { version = "0.19.9", path = "../xcb-runtime" }
zeroize = "=1.9.0"

[target.'cfg(unix)'.dependencies]
Expand Down
55 changes: 37 additions & 18 deletions crates/xcb-cli/src/habitat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,12 @@ pub enum ScheduleCommand {
/// Move the next wake-up to this many seconds from now.
#[arg(long, value_parser = clap::value_parser!(u64).range(0..=31_536_000))]
next_in: Option<u64>,
/// For unattended work: once a task in this schedule's directory has
/// an uncertain outcome and its run has stopped, close it as failed
/// (as `xcb backlog dismiss` does, never retried) so the next wake-up
/// can go ahead and inspect it. `false` turns this off.
#[arg(long)]
dismiss_uncertain: Option<bool>,
},
/// Delete a schedule. Tasks it already created keep their history and
/// finish on their own terms.
Expand Down Expand Up @@ -1437,30 +1443,43 @@ pub async fn schedules(
prompt,
every,
next_in,
dismiss_uncertain,
}) => {
let next_due_ms = next_in
.map(|seconds| {
seconds
.checked_mul(1000)
.and_then(|delay| now_ms().checked_add(delay))
.ok_or(Error::Unavailable("schedule time overflow"))
})
.transpose()?;
let schedule = store.update_schedule(
&store.resolve_schedule(&id)?,
revision,
prompt,
every
let id = store.resolve_schedule(&id)?;
let mut revision = revision;
let mut schedule = None;
if let Some(dismiss) = dismiss_uncertain {
let updated = store.set_schedule_dismissal(&id, revision, dismiss)?;
revision = updated.revision;
schedule = Some(updated);
}
if schedule.is_none() || prompt.is_some() || every.is_some() || next_in.is_some() {
let next_due_ms = next_in
.map(|seconds| {
seconds
.checked_mul(1000)
.ok_or(Error::Unavailable("schedule interval overflow"))
.and_then(|delay| now_ms().checked_add(delay))
.ok_or(Error::Unavailable("schedule time overflow"))
})
.transpose()?,
next_due_ms,
)?;
.transpose()?;
schedule = Some(
store.update_schedule(
&id,
revision,
prompt,
every
.map(|seconds| {
seconds
.checked_mul(1000)
.ok_or(Error::Unavailable("schedule interval overflow"))
})
.transpose()?,
next_due_ms,
)?,
);
}
wake(root)?;
vec![schedule]
vec![schedule.expect("an edit always changes the schedule")]
}
Some(ScheduleCommand::Delete { .. }) => unreachable!("handled above"),
};
Expand Down
2 changes: 1 addition & 1 deletion crates/xcb-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ thiserror = "=2.0.21"
proptest = "=1.11.0"

[target.'cfg(windows)'.dependencies]
xcb-platform = { version = "0.19.8", path = "../xcb-platform" }
xcb-platform = { version = "0.19.9", path = "../xcb-platform" }
4 changes: 2 additions & 2 deletions crates/xcb-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ tokio = { version = "=1.47.1", features = ["rt-multi-thread", "macros", "process
tokio-rustls = { version = "=0.26.5", default-features = false, features = ["ring", "tls12"] }
uuid = { version = "=1.26.1", features = ["v4", "v7", "serde"] }
webpki-roots = "=1.0.9"
xcb-core = { version = "0.19.8", path = "../xcb-core" }
xcb-core = { version = "0.19.9", path = "../xcb-core" }
zeroize = "=1.9.0"

[target.'cfg(unix)'.dependencies]
Expand All @@ -46,4 +46,4 @@ local-custody = { git = "https://github.com/hraness/local-custody", rev = "4fd65
rustix = { version = "=1.1.5", features = ["fs", "process", "pty", "termios"] }

[target.'cfg(windows)'.dependencies]
xcb-platform = { version = "0.19.8", path = "../xcb-platform" }
xcb-platform = { version = "0.19.9", path = "../xcb-platform" }
14 changes: 14 additions & 0 deletions crates/xcb-runtime/src/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ impl ManagedConversation {
pub(crate) const DISMISSED_DETAIL: &str =
"dismissed by the owner with unreconciled effects; no retry launched";

/// The detail a schedule's standing dismissal records.
pub(crate) const SCHEDULE_DISMISSED_DETAIL: &str = "dismissed under its schedule's standing owner instruction with unreconciled effects; no retry launched";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManagedTask {
Expand Down Expand Up @@ -5856,6 +5859,9 @@ impl Supervisor {
// never skips dispatch or counts toward the supervisor's own fault
// limit: one damaged row must not stop every other task, or restart
// the supervisor into the same failure.
if !draining && let Err(error) = self.managed.tick_schedule_dismissals(&self.store).await {
self.upkeep_fault("schedule dismissal", &error);
}
if let Err(error) = self.managed.tick_programs(&self.store, !draining).await {
self.upkeep_fault("program", &error);
}
Expand Down Expand Up @@ -6614,6 +6620,7 @@ pub async fn daemon(root: PathBuf) -> Result<i32> {
),
);
}
let native_store = store.clone();
let mut supervisor = Supervisor::new(managed.clone(), store);
let spawn_refresh = |root: PathBuf| {
tokio::task::spawn_blocking(move || {
Expand All @@ -6625,6 +6632,7 @@ pub async fn daemon(root: PathBuf) -> Result<i32> {
let fault_root = managed.root().to_path_buf();
let spawn_pins = |root: PathBuf| {
let fault_root = fault_root.clone();
let native_store = native_store.clone();
tokio::spawn(async move {
let home = root.join("metadata-home");
let catalog_root = root.clone();
Expand All @@ -6636,6 +6644,12 @@ pub async fn daemon(root: PathBuf) -> Result<i32> {
record_supervisor_fault(&fault_root, &format!("{provider} refresh: {detail}"));
}
}
// An upgrade or adopted provider build voids native receipts;
// re-run the checks the owner already passed so granted
// workspaces keep routing instead of silently stalling.
for fault in crate::native_verification::requalify_lapsed(native_store).await {
record_supervisor_fault(&fault_root, &fault);
}
})
};
let mut offer_refresh = Some(spawn_refresh(root.clone()));
Expand Down
63 changes: 63 additions & 0 deletions crates/xcb-runtime/src/managed_habitat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ pub struct HabitatSchedule {
pub interval_ms: u64,
pub next_due_ms: u64,
pub enabled: bool,
/// The owner's standing instruction for an unattended herd: once no run
/// holds an uncertain task in this schedule's directory, the supervisor
/// dismisses it as `xcb backlog dismiss` would, so the next wake-up can
/// inspect it instead of waiting for the owner. Never retries the work.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dismiss_released_uncertainty: bool,
pub last_task: Option<Id>,
pub revision: u64,
pub created_at_ms: u64,
Expand Down Expand Up @@ -1219,6 +1225,7 @@ impl ManagedStore {
interval_ms,
next_due_ms: first_due_ms,
enabled: true,
dismiss_released_uncertainty: false,
last_task: None,
revision: 1,
created_at_ms: now,
Expand Down Expand Up @@ -1304,6 +1311,62 @@ impl ManagedStore {
Ok(next)
}

/// Turn the schedule's standing dismissal of released uncertain work on
/// or off.
pub fn set_schedule_dismissal(
&self,
id: &Id,
expected_revision: u64,
dismiss_released_uncertainty: bool,
) -> Result<HabitatSchedule> {
let mut db = self.write_db()?;
let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current = schedule_from(&tx, id)?.ok_or(Error::Unavailable("schedule not found"))?;
if current.revision != expected_revision {
return Err(Error::Conflict("schedule revision changed"));
}
let mut next = current.clone();
next.dismiss_released_uncertainty = dismiss_released_uncertainty;
next.revision += 1;
next.updated_at_ms = now_ms().max(current.updated_at_ms);
write_schedule(&tx, &current, &next)?;
tx.commit()?;
Ok(next)
}

/// Dismiss uncertain tasks in the directories of enabled schedules that
/// carry the owner's standing dismissal, once no run holds them. A task
/// still held by a run or a delivered reply stays uncertain.
pub async fn tick_schedule_dismissals(&self, store: &Store) -> Result<()> {
let mut uncertain = Vec::new();
{
let db = self.db()?;
for schedule in self.schedules_in(&db, None)? {
if !schedule.enabled || !schedule.dismiss_released_uncertainty {
continue;
}
let Ok(workspace) = schedule_workspace(&db, &schedule) else {
continue;
};
uncertain.extend(
project::outstanding_in(&db, &workspace, None)?
.into_iter()
.filter(|task| task.state == TaskState::Uncertain),
);
}
}
for task in uncertain {
match self
.dismiss_uncertain_as(store, &task.id, task.revision, SCHEDULE_DISMISSED_DETAIL)
.await
{
Ok(_) | Err(Error::Conflict(_)) => {}
Err(error) => return Err(error),
}
}
Ok(())
}

/// Delete a schedule row; occurrences already queued are unaffected.
/// The revision check keeps a stale handle from deleting a newer edit.
pub fn delete_schedule(&self, id: &Id, expected_revision: u64) -> Result<()> {
Expand Down
43 changes: 43 additions & 0 deletions crates/xcb-runtime/src/managed_habitat_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1331,3 +1331,46 @@ async fn backlog_add_refuses_a_pin_that_contradicts_a_required_provider() {
);
assert!(task.provider_required);
}

#[tokio::test]
async fn a_schedule_dismisses_released_uncertainty_only_when_the_owner_opted_in() {
let f = fixture().await;
let store = Store::open(f.managed.root().parent().unwrap()).unwrap();
let schedule = f
.managed
.create_schedule(&f.conversation, "herd".into(), 60_000, now_ms())
.await
.unwrap();
let open = enqueue(&f, "unprovable", false).await;
let uncertain = set_state(&f, &open, TaskState::Uncertain).await;
// Off by default: a timer never infers that uncertainty settled.
f.managed.tick_schedule_dismissals(&store).await.unwrap();
assert_eq!(
f.managed.task(&uncertain.id).unwrap().unwrap().state,
TaskState::Uncertain
);
assert_eq!(
f.managed
.schedule_view(&schedule)
.unwrap()
.blocker
.as_deref(),
Some("1 open task in this project")
);
let opted = f
.managed
.set_schedule_dismissal(&schedule.id, schedule.revision, true)
.unwrap();
assert!(opted.dismiss_released_uncertainty);
assert!(
f.managed
.set_schedule_dismissal(&schedule.id, schedule.revision, false)
.is_err()
);
f.managed.tick_schedule_dismissals(&store).await.unwrap();
let dismissed = f.managed.task(&uncertain.id).unwrap().unwrap();
assert_eq!(dismissed.state, TaskState::Failed);
assert!(dismissed.dismissed);
assert_eq!(dismissed.detail, SCHEDULE_DISMISSED_DETAIL);
assert!(f.managed.schedule_view(&opted).unwrap().blocker.is_none());
}
13 changes: 12 additions & 1 deletion crates/xcb-runtime/src/managed_project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,17 @@ impl ManagedStore {
store: &Store,
id: &Id,
expected_revision: u64,
) -> Result<ManagedTask> {
self.dismiss_uncertain_as(store, id, expected_revision, super::DISMISSED_DETAIL)
.await
}

pub(super) async fn dismiss_uncertain_as(
&self,
store: &Store,
id: &Id,
expected_revision: u64,
detail: &str,
) -> Result<ManagedTask> {
let task = self
.task(id)?
Expand Down Expand Up @@ -878,7 +889,7 @@ impl ManagedStore {
next.dismissed = true;
next.next_prompt.clear();
next.attachments.clear();
next.detail = super::DISMISSED_DETAIL.into();
next.detail = detail.into();
next.revision += 1;
next.updated_at_ms = now_ms().max(task.updated_at_ms);
let message = Self::assistant(
Expand Down
Loading
Loading