Execution & Interrupt
In complex workflow scenarios, execution alone is not enough. We need the ability to pause the execution flow when necessary, allow external inspection or intervention, and then continue running. AmritaSense builds this capability into the core of the interpreter.
Interrupt mechanism overview
AmritaSense's interpreter interrupt system is divided into two fundamentally different modes:
| Mode | Trigger source | Controllability | Consequence |
|---|---|---|---|
| Internal Interrupt | Exception thrown inside a node (including InterruptNotice) | Regular exceptions suppressible via Try/CATCH; InterruptNotice (BaseException) is internally uncontrollable | Regular exception uncaught -> Panic state (Interpreter Dumped); InterruptNotice -> clears stack & pointer, workflow terminates |
| External Interrupt | External code injects a sub-call via call_sub(interrupt=True) mid-cycle | Externally driven, internally uncontrollable | Sub-call executes under interpreter lock protection, serialized with main loop |
Both interrupt modes are coordinated through a single interpreter lock (_interpret_lock):
- Internal Interrupt: The interpreter main loop acquires
_interpret_lockbefore executing a node; exceptions are thrown inside the lock and propagate through the normal exception path. - External Interrupt:
call_sub(interrupt=True)acquires_interpret_lockto serialize with the main loop;interrupt=Falsebypasses the lock and runs outside the interrupt-safe zone.
Internal Interrupt — Node exception system
Internal interrupts originate from inside the workflow. Any exception thrown during node execution falls under the internal interrupt category. Based on the exception type, internal interrupts split into two paths.
Path 1: Suppressible exceptions (regular Exception subclasses) -> Panic / Recover
Regular exceptions (inheriting from Exception) can be suppressed by Try/CATCH blocks. AmritaSense provides two suppression mechanisms:
Try/CATCHworkflow nodes: Explicitly specify which exception types should be caught and suppressed.exception_ignoredinit parameter: Pass a tuple of exception types that will bypass allTry/CATCHblocks.
from amrita_sense.runtime import WorkflowInterpreter
interpreter = WorkflowInterpreter(
workflow,
exception_ignored=(ValueError, TypeError),
)Note:
InterruptNoticeandBreakLoopare added toexception_ignoredby default.
Panic and Recover flow:
Key state after Panic:
_panic_exc: records the exception (query viainterpreter.get_exception())_waiter_fut: set to exception state (set_exception(exc_val))- Pointer vector and call stack: preserved (not cleared)
try:
await interpreter.run()
except Exception as e:
exc = interpreter.get_exception()
print(f"Panic caused by: {exc}")
await interpreter.run() # "Recovered from panic."Path 2: Unsuppressible exception (InterruptNotice) -> Flow termination
InterruptNotice inherits from BaseException, not Exception:
class InterruptNotice(BaseException):
def __init__(self, message: str | None = None):
self.message = messagePython's except Exception does not catch BaseException subclasses. Therefore:
- No
Trynode can suppressInterruptNotice try/except Exceptioninside node code cannot catch it- Only the interpreter main loop's outermost
except InterruptNoticeresponds
Trigger methods:
Raise externally:
pythonraise InterruptNotice("Timeout: workflow exceeded time limit")Insert
RESETnode in workflow:pythonfrom amrita_sense.instructions import RESET workflow = Sequence(StepA(), Branch(If(condition=is_error, then=RESET), ...))
InterruptKeepContext
InterruptKeepContext is a subclass of InterruptNotice that provides a context-preserving variant. When the interpreter catches it, instead of calling reset(), the pointer, call stacks, dependency injection parameters and status register are left intact. Execution can be resumed by calling run() again on the same interpreter.
| Exception | After catch | Recoverable | Node |
|---|---|---|---|
InterruptNotice | reset() — clears all state | ❌ | RESET |
InterruptKeepContext | Skips reset() — state preserved | ✅ run() | SUSPEND |
Trigger methods:
- Insert
SUSPENDnode in the workflow (fromamrita_sense.instructions.workfl_ctrl) - Raise
InterruptKeepContextdirectly from node code
Resuming: the HLT flag
When a suspend is caught the pointer still addresses the node that raised it, so re-running the loop would simply execute that node again and halt in the same place forever. To prevent that, the interpreter sets HLT in its status register:
if not isinstance(e, InterruptKeepContext):
self.reset()
else:
self._flags |= Flags.HLT # pointer still addresses the halting nodeThe next run consumes the bit before executing anything, stepping past the node instead of re-running it:
if self._flags & Flags.HLT:
if not self.resume_from_halt():
break
continueresume_from_halt() is the single implementation shared by the main loop and the debugger's step, so stepping past a suspend behaves exactly like running past it.
The bit is cleared as soon as the pointer is moved deliberately — by any @markup jump method, by rebase_ptr(), or by reset(). dump_interpreter() also strips it, so a context snapshot never carries the halt and restoring one cannot resurrect it.
This is termination, not suspension. For a plain InterruptNotice, the call stack and pointer are fully cleared; the workflow exits and cannot be resumed from the interruption point. To re-execute, the workflow must be re-rendered and a new interpreter instance created.
Internal Interrupt Summary
| Path | Exception type | Suppressible | If uncaught | Recoverable |
|---|---|---|---|---|
| Suppressible | Exception subclasses | ✅ Try/CATCH | Panic -> Interpreter Dumped | ✅ Recovered from Panic |
| Unsuppressible | InterruptNotice (BaseException) | ❌ | Clear stack & pointer, terminate | ❌ |
| Keep-context | InterruptKeepContext | ❌ | Preserve state, wait for resume | ✅ run() again |
External Interrupt — call_sub(interrupt=True)
External interrupts are fundamentally different from exceptions. Through call_sub(interrupt=True), external code injects protected sub-calls mid-cycle into the interpreter.
Core mechanism: the interpreter lock (_interpret_lock)
The interpreter main loop acquires _interpret_lock before each node:
while True:
await self.object_io._wait_for_continue(
PC_CHECKPOINT
) # Suspend check (outside lock)
async with self._interpret_lock: # Enter interrupt-safe zone
yield await self._call() # Execute nodeWhen external code calls call_sub(interrupt=True), the caller and main loop compete for _interpret_lock:
interrupt | Behavior | Semantics |
|---|---|---|
True | Acquires _interpret_lock before executing | Sub-call runs in the interrupt-safe zone — serialized with main loop; external can inject mid-cycle |
False (default) | Bypasses the lock | Sub-call runs outside the safe zone, suitable for internal helper calls |
Why is it called "external interrupt"?
When interrupt=True, the timing of the sub-call is determined by the external caller — external code invokes call_sub(interrupt=True) at any point in the interpreter cycle, the sub-call waits to acquire _interpret_lock (competing with the main loop), then executes. Internal code has no control over when this happens.
Unlike InterruptNotice (which is still an exception thrown from within), call_sub(interrupt=True) is a truly externally driven interrupt: the external caller decides the timing, content, and duration.
Design implications
- Suspend checks happen outside the lock:
_wait_for_continueruns before lock acquisition - Node execution is inside the lock: Guarantees state consistency during execution
- External interrupt is mutually exclusive with the main loop: Only one runs inside the lock at a time
outer_interpreting
During any call_sub execution (regardless of interrupt), the read-only property outer_interpreting is True; it is cleared when the call returns. INT consults it when ret_to is None: inside a call it reuses the top of _ret_addr_stack (the parent's return address), otherwise it uses the current pointer. CALL does not consult it — its from_adr=None default always resolves to the current pointer (the CALL node itself), so the return address it pushes is identical in the main flow and inside a subroutine.
Interaction model for suspension (cooperative suspend points)
The suspension model divides the participants into two roles.
The underlying capability is provided by the SuspendObjectStream base class. The interpreter uses object_io._wait_for_continue() to pause at two kinds of tags: WorkflowInterpreter::each_node for the between-node global breakpoint, and each node's own NodeSuspend::{node name}-style tag for pre-execution suspension.
Full suspension interaction sequence diagram
Stateful vs. Stateless distinction
SuspendObjectStreamholds the suspend signal (stateful): When external callswait_to_suspend(), a suspend signal (__suspend_signalFuture) is written into the SoS instance and persists until consumed by a matching breakpoint.- The interpreter polls the suspend signal (stateless): At each clock cycle (node boundary), the interpreter performs an instantaneous check on SoS.
Thus, the suspend signal is persistent state, while the interrupt check is an instantaneous action.
Between-node breakpoint
Trigger timing: after each node completes, as the interpreter enters the next loop iteration, after advancing the pointer and resolving the next location, but before executing the next node.
Identifier: WorkflowInterpreter::each_node (constant PC_CHECKPOINT).
This is the most general, global suspension point. When suspended here, developers can:
- view or modify the current pointer vector to decide which node executes next
- redirect the subsequent execution target
WARNING
If you redirect the target at this point, you must operate directly on the pointer. Do not use standard jump APIs, or you may corrupt interpreter state.
Pre-execution breakpoint
Trigger timing: after a specific node is loaded by the address resolver, but before its function body executes.
Identifier: defaults to NodeSuspend::{node name}, or can be defined by a custom tag string.
When suspended here:
- a full address snapshot has already been preserved
- the recorded address is of the node that will run next
- forcing the pointer to advance directly here may break interpreter consistency
WARNING
Any jump or modification at this breakpoint must use official interpreter APIs (jump_to, jump_near, etc.).
Event system and custom hooks
AmritaSense also provides a runtime event/hook system. Custom events are defined by subclassing BaseEvent, handlers are registered with on_event(event_type), and events are dispatched through MatcherFactory.trigger_event(...).
For a full description, see: Advanced > Event System.
Summary
AmritaSense's interruption system covers the full spectrum from node-level exceptions to externally injected sub-calls:
| Mechanism | Type | Recoverable | Trigger source | Controllability |
|---|---|---|---|---|
Cooperative suspend (wait_to_suspend/resume) | Suspend | ✅ Recoverable | External, proactive | Internal cooperates |
| Internal·suppressible (node exception -> Panic) | Interrupt | ✅ Recoverable via Recover | Inside node | Suppressible via Try/CATCH |
Internal·unsuppressible (InterruptNotice) | Interrupt | ❌ Irrecoverable | Inside node / external throw | Internally uncontrollable |
External Interrupt (call_sub(interrupt=True)) | Interrupt | No "recovery" concept | External caller | Internally uncontrollable, serialized by lock |
The core value of this system is:
- Debuggability: developers can pause, inspect, and single-step at any node boundary.
- Intervenability: external systems can inject sub-calls mid-cycle via
call_sub(interrupt=True). - Fault tolerance: the Panic + Recover pattern enables graceful recovery after regular exceptions.
- Safety:
InterruptNoticeterminates workflows unconditionally; the external interrupt lock mechanism guarantees state consistency.
In the advanced chapters, we will explore how to combine this interruption mechanism with interpreter locks and external calls to build a full debugger or external monitoring system.
REPL Debugger
Building on this interrupt infrastructure, the amrita_sense.debugger module adds breakpoints, stepping, and state inspection — a complete REPL debugging experience. Import everything with from amrita_sense.debugger import *; all functions are callable synchronously. See REPL Debugging for details.
