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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to `devloop` will be recorded in this file.

## [Unreleased]

## [0.10.2] - 2026-08-26

### Fixed

- Invalidated process-output state before every start attempt and when the
process stops or exits, preventing stale values such as tunnel URLs from
surviving a missing executable or dead process.
- Made `wait_for_process` reject stopped and failed processes even when their
readiness state persists from an earlier run.
- Logged complete workflow failure chains while explicitly continuing the
runtime in degraded mode.

## [0.10.1] - 2026-08-26

### Changed
Expand Down
2 changes: 1 addition & 1 deletion 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
@@ -1,6 +1,6 @@
[package]
name = "devloop"
version = "0.10.1"
version = "0.10.2"
edition = "2024"

[dependencies]
Expand Down
11 changes: 9 additions & 2 deletions docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,13 @@ Managed processes are long-running child commands.
escapes portable Unix process-group containment. Such commands must
provide their own shutdown integration instead of daemonizing beneath
`devloop`.
- `wait_for_process` waits on the configured readiness probe, not just
on successful spawning.
- `wait_for_process` requires the named process to be running while it
waits on the configured readiness probe. Persisted readiness state from
an earlier process instance cannot make a stopped or failed process ready.
- State keys populated by a process's output rules belong to that process
instance. `devloop` clears them before every start attempt and when the
process stops or exits, so a failed dependency cannot leave a stale URL or
other process-derived value available to later workflows.
- `restart = "always"` restarts a child after any exit unless
`devloop` is shutting down.
- `restart = "on_failure"` restarts only after unsuccessful exit.
Expand All @@ -139,6 +144,8 @@ Managed processes are long-running child commands.
probe is checked.
- Missing or malformed environment references fail loudly with the
field name so the configuration error is visible.
- Workflow failures include their complete causal error chain and leave the
runtime watching in degraded mode when it is safe to continue.

Liveness probes are checked on the configured interval while the process
is running. If a liveness probe fails and the restart policy allows it,
Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ Use `plain` when subprocess color or exact body rendering matters. Use
### Output rules

Each rule extracts a value from process output and writes it into the
session state.
session state. The value belongs to the current process instance: `devloop`
clears it before each start attempt and when the process stops or exits.
Consequently, a missing executable or other startup failure cannot leave a
value captured by an earlier run available to later workflows.

```toml
output = { rules = [{ state_key = "tunnel_url", extract = "url_token" }] }
Expand Down
21 changes: 18 additions & 3 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ impl WorkflowEffectAdapter for LiveWorkflowAdapter<'_, '_> {
}

async fn stop_process(&mut self, process: &str) -> Result<()> {
self.processes.stop_named(process).await
self.processes.stop_named(process, self.state).await
}

async fn restart_process(&mut self, process: &str) -> Result<()> {
Expand Down Expand Up @@ -421,8 +421,8 @@ async fn execute_runtime_effects<A: RuntimeEffectAdapter>(
if let Err(error) = adapter.run_workflow(&workflow_name, &changed_files).await {
error!(
workflow = %workflow_name,
error = %error,
"workflow failed; continuing runtime"
error = %workflow_failure_chain(&error),
"workflow failed; continuing runtime in degraded mode"
);
}
}
Expand All @@ -446,6 +446,10 @@ async fn execute_runtime_effects<A: RuntimeEffectAdapter>(
Ok(false)
}

fn workflow_failure_chain(error: &anyhow::Error) -> String {
format!("{error:#}")
}

fn forward_watcher_event(
tx: &tokio::sync::mpsc::UnboundedSender<notify::Result<Event>>,
shutting_down: &AtomicBool,
Expand Down Expand Up @@ -745,6 +749,17 @@ mod tests {
std::env::temp_dir().join(format!("devloop-engine-state-{unique}.json"))
}

#[test]
fn workflow_failure_chain_includes_root_cause() {
let error = anyhow!("executable 'cloudflared' was not found")
.context("guardian failed to start process 'tunnel'");

let message = workflow_failure_chain(&error);

assert!(message.contains("guardian failed to start process 'tunnel'"));
assert!(message.contains("executable 'cloudflared' was not found"));
}

#[test]
fn classify_changes_by_workflow() {
let root = PathBuf::from("/tmp/example");
Expand Down
Loading