diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0d9f5..284fe29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to this project will be documented in this file. invocations and emitting lifecycle events. - Explicit safe-point state migration during implementation installation, with atomic failure behavior and migration events. +- Explicit invocation cancellation that requeues the reserved message for + retry, exposed in the CLI and documented with native side-effect caveats. - Execution-model decision and Phase 1 runtime documentation. - Lifecycle tests proving failed-message retry from committed state and stop/restart preservation of reserved-first FIFO order. diff --git a/README.md b/README.md index a2fc6ae..9b5032a 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,10 @@ The sender commits state `1` and sends a message to actor 2, which commits state `15`. The CLI also supports publishing Rune source files as named, versioned behaviors, viewing history, rolling back a behavior reference, installing the current version on an idle actor, and cooperatively pausing and -resuming actors between scheduler ticks. Runtime users can also supply an -explicit state migration closure during installation; the CLI's install -command preserves state as-is. Use `help` in the session for the complete -command list. +resuming or cancelling actor invocations between scheduler ticks. Runtime +users can also supply an explicit state migration closure during installation; +the CLI's install command preserves state as-is. Use `help` in the session for +the complete command list. See [the feasibility report](docs/rune-feasibility.md) for verified Rune capabilities and limitations, and [the runtime guide](docs/runtime.md) for the runtime contract. diff --git a/crates/runa-actor/src/lib.rs b/crates/runa-actor/src/lib.rs index f093bae..523e26b 100644 --- a/crates/runa-actor/src/lib.rs +++ b/crates/runa-actor/src/lib.rs @@ -45,6 +45,7 @@ pub enum Event { Restarted(ActorId), Paused(ActorId), Resumed(ActorId), + InvocationCancelled(ActorId), BehaviorInstalled(ActorId, ImplementationId), StateMigrated(ActorId, i64, i64), } diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index 336c6a2..54a2249 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -161,6 +161,27 @@ impl Runtime { Ok(()) } + /// Cancel an active handler and requeue its reserved message for retry. + /// + /// This does not roll back side effects already performed by native code. + pub fn cancel(&mut self, id: ActorId) -> ExperimentResult<()> { + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if actor.active.is_none() { + return Err("actor has no active invocation to cancel".into()); + } + let message = actor + .snapshot + .reserved + .take() + .ok_or("active invocation has no reserved message")?; + actor.active = None; + actor.paused_from = None; + actor.mailbox.push_front(message); + actor.snapshot.status = Status::Idle; + self.events.push(Event::InvocationCancelled(id)); + Ok(()) + } + pub fn restart(&mut self, id: ActorId) -> ExperimentResult<()> { let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; if !matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { @@ -518,14 +539,14 @@ mod tests { runtime.tick(capacity(5)); } assert_eq!(runtime.inspect(actor)?.status, Status::Awaiting); - runtime.stop(actor)?; + runtime.cancel(actor)?; assert!(gate.dropped.load(Ordering::SeqCst)); - runtime.restart(actor)?; - let restarted = runtime.inspect(actor)?; - assert_eq!(restarted.mailbox, pending.mailbox); - assert_eq!(restarted.state, 20); - assert_eq!(restarted.incarnation, 2); - assert_eq!(restarted.queued.len(), 1); + let cancelled = runtime.inspect(actor)?; + assert_eq!(cancelled.mailbox, pending.mailbox); + assert_eq!(cancelled.state, 20); + assert_eq!(cancelled.status, Status::Idle); + assert_eq!(cancelled.queued, vec![Message { kind: 1, value: 0 }]); + assert!(cancelled.reserved.is_none()); Ok(()) } @@ -712,6 +733,50 @@ mod tests { Ok(()) } + #[test] + fn cancelled_input_retries_first_with_new_behavior() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let old_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state + value, []) }", + )?; + let new_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state * 10 + value, []) }", + )?; + let actor = runtime.spawn(old_implementation, 0, capacity(4))?; + runtime.send(actor, Message { kind: 0, value: 1 })?; + runtime.send(actor, Message { kind: 0, value: 2 })?; + runtime.tick(capacity(1)); + + assert_eq!(runtime.inspect(actor)?.status, Status::Running); + runtime.cancel(actor)?; + let cancelled = runtime.inspect(actor)?; + assert_eq!(cancelled.status, Status::Idle); + assert_eq!(cancelled.state, 0); + assert_eq!( + cancelled.queued, + vec![Message { kind: 0, value: 1 }, Message { kind: 0, value: 2 }] + ); + assert!(cancelled.reserved.is_none()); + + runtime.install(actor, new_implementation)?; + runtime.send(actor, Message { kind: 0, value: 3 })?; + for _ in 0..100 { + runtime.tick(capacity(10)); + } + let completed = runtime.inspect(actor)?; + assert_eq!(completed.state, 123); + assert_eq!(completed.implementation, new_implementation); + assert!( + runtime + .drain_events() + .iter() + .any(|event| matches!(event, Event::InvocationCancelled(id) if *id == actor)) + ); + Ok(()) + } + #[test] fn failed_state_migration_preserves_actor_state_and_implementation() -> ExperimentResult<()> { let mut runtime = Runtime::default(); diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index fd85d6c..90b2210 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -135,6 +135,12 @@ fn run_repl( |runtime, id| runtime.resume(id), runtime, ), + "cancel" => lifecycle_command( + &mut output, + &args, + |runtime, id| runtime.cancel(id), + runtime, + ), "quit" | "exit" if args.is_empty() => break, "quit" | "exit" => writeln!(output, "error: usage: quit"), _ => writeln!(output, "error: unknown command `{command}`; type `help`"), @@ -392,6 +398,7 @@ fn print_help(output: &mut W) -> io::Result<()> { restart \n\ pause \n\ resume \n\ + cancel \n\ help\n\ quit" ) @@ -481,7 +488,7 @@ mod tests { "/../../runa-std/actors/counter.rn" ); let input = io::Cursor::new(format!( - "behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nspawn 0 counter\ninstall 1 counter\npause 1\nresume 1\ninspect 1\nevents\nquit\n" + "behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nspawn 0 counter\ninstall 1 counter\nsend 1 0 1\ntick 1 1\ncancel 1\npause 1\nresume 1\ninspect 1\nevents\nquit\n" )); let mut output = Vec::new(); run_repl( @@ -500,6 +507,7 @@ mod tests { assert!(output.contains("installed `counter` revision 3 on actor 1")); assert!(output.contains("Paused(")); assert!(output.contains("Resumed(")); + assert!(output.contains("InvocationCancelled(")); assert_eq!(code.behavior(DEFAULT_BEHAVIOR).unwrap().revision, 3); assert_eq!(runtime.inspect(ActorId(1))?.implementation, implementation); Ok(()) diff --git a/docs/runtime.md b/docs/runtime.md index 92e8dc2..338a909 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -8,12 +8,13 @@ mise run run The executable loads [counter.rn](../runa-std/actors/counter.rn) as the default behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`, -`events`, `stop`, `restart`, `pause`, `resume`, `behaviors`, `publish`, -`history`, `rollback`, and `install`; enter `help` to see usage. `publish` reads a Rune source file and -takes `new` for a first revision or the expected current revision for an -update. `rollback` requires the expected current revision and a target -historical revision. `install` selects a named behavior's current implementation and fails if the -actor is not at an idle handler boundary. +`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`, +`publish`, `history`, `rollback`, and `install`; enter `help` to see usage. +`publish` reads a Rune source file and takes `new` for a first revision or the +expected current revision for an update. `rollback` requires the expected +current revision and a target historical revision. `install` selects a named +behavior's current implementation and fails if the actor is not at an idle +handler boundary. `spawn` accepts an optional behavior name and otherwise uses `counter`. All commands operate on the same process-local runtime and code store. This CLI is for local development, not a versioned external API. @@ -87,6 +88,14 @@ only if its future has been woken. These operations take effect between calls to `tick` and cannot interrupt a currently executing Rune slice or native callback. They provide cooperative scheduling control, not preemption. +`cancel` is distinct from `stop`: it drops an actor's active invocation, moves +its reserved message to the front of the mailbox, and returns the actor to +`Idle`. The input is therefore eligible for retry on the next tick, ahead of +messages that were already queued. Committed state is unchanged and no +transition effects are applied. Cancellation is also available for paused +invocations. It does not undo external side effects already performed by +native callbacks, so retries may repeat such effects. + The source identity hashes the exact original UTF-8 bytes with the versioned `runa-source:v1` domain. Whitespace/comment canonicalization is intentionally not attempted; textually different source has a different identity. The @@ -154,6 +163,7 @@ resolves it; there is no automatic poison-message policy or supervision. | Restart after stop or failure | Retained until retried | Moved to queue front | None until retry completes | `Idle` | | Pause between ticks | Last committed state retained | Retained | None while paused | `Paused` | | Resume a paused invocation | Retained until invocation completes | Retained | None until completion | Prior runnable/awaiting status | +| Cancel an invocation | Last committed state retained | Moved to queue front | None from incomplete transition | `Idle` | Mailbox capacity counts queued plus reserved messages. Before applying a transition, the runtime validates all effects, including aggregate sends to a @@ -166,11 +176,13 @@ These are process-local guarantees only. A host crash loses state, mailboxes, reservations and events. External/native callback effects may already have occurred even when the Rune invocation later fails or is stopped. -Lifecycle tests verify that a failed reservation retries from the last -committed state, that stop/restart preserves reserved-first FIFO order, and -that a running actor's queued messages are not processed until the reservation -finishes. Transition tests cover malformed results, runtime failures, invalid -targets, aggregate capacity, and successful message effects. +Lifecycle tests verify that failed and cancelled reservations retry from the +last committed state, that stop/restart preserves reserved-first FIFO order, +and that cancellation followed by installation retries queued input in order +with the new implementation. Tests also cover that a running actor's queued +messages are not processed until the reservation finishes. Transition tests +cover malformed results, runtime failures, invalid targets, aggregate +capacity, and successful message effects. ## Limits