Skip to content

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:

ModeTrigger sourceControllabilityConsequence
Internal InterruptException thrown inside a node (including InterruptNotice)Regular exceptions suppressible via Try/CATCH; InterruptNotice (BaseException) is internally uncontrollableRegular exception uncaught -> Panic state (Interpreter Dumped); InterruptNotice -> clears stack & pointer, workflow terminates
External InterruptExternal code injects a sub-call via call_sub(interrupt=True) mid-cycleExternally driven, internally uncontrollableSub-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_lock before executing a node; exceptions are thrown inside the lock and propagate through the normal exception path.
  • External Interrupt: call_sub(interrupt=True) acquires _interpret_lock to serialize with the main loop; interrupt=False bypasses 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:

  1. Try/CATCH workflow nodes: Explicitly specify which exception types should be caught and suppressed.
  2. exception_ignored init parameter: Pass a tuple of exception types that will bypass all Try/CATCH blocks.
python
from amrita_sense.runtime import WorkflowInterpreter

interpreter = WorkflowInterpreter(
    workflow,
    exception_ignored=(ValueError, TypeError),
)

Note: InterruptNotice and BreakLoop are added to exception_ignored by default.

Panic and Recover flow:

Key state after Panic:

  • _panic_exc: records the exception (query via interpreter.get_exception())
  • _waiter_fut: set to exception state (set_exception(exc_val))
  • Pointer vector and call stack: preserved (not cleared)
python
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:

python
class InterruptNotice(BaseException):
    def __init__(self, message: str | None = None):
        self.message = message

Python's except Exception does not catch BaseException subclasses. Therefore:

  • No Try node can suppress InterruptNotice
  • try/except Exception inside node code cannot catch it
  • Only the interpreter main loop's outermost except InterruptNotice responds

Trigger methods:

  1. Raise externally:

    python
    raise InterruptNotice("Timeout: workflow exceeded time limit")
  2. Insert RESET node in workflow:

    python
    from 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.

ExceptionAfter catchRecoverableNode
InterruptNoticereset() — clears all state❌RESET
InterruptKeepContextSkips reset() — state preserved✅ run()SUSPEND

Trigger methods:

  1. Insert SUSPEND node in the workflow (from amrita_sense.instructions.workfl_ctrl)
  2. Raise InterruptKeepContext directly 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:

python
if not isinstance(e, InterruptKeepContext):
    self.reset()
else:
    self._flags |= Flags.HLT  # pointer still addresses the halting node

The next run consumes the bit before executing anything, stepping past the node instead of re-running it:

python
if self._flags & Flags.HLT:
    if not self.resume_from_halt():
        break
    continue

resume_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 ​

PathException typeSuppressibleIf uncaughtRecoverable
SuppressibleException subclasses✅ Try/CATCHPanic -> Interpreter Dumped✅ Recovered from Panic
UnsuppressibleInterruptNotice (BaseException)❌Clear stack & pointer, terminate❌
Keep-contextInterruptKeepContext❌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:

python
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 node

When external code calls call_sub(interrupt=True), the caller and main loop compete for _interpret_lock:

interruptBehaviorSemantics
TrueAcquires _interpret_lock before executingSub-call runs in the interrupt-safe zone — serialized with main loop; external can inject mid-cycle
False (default)Bypasses the lockSub-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_continue runs 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

  • SuspendObjectStream holds the suspend signal (stateful): When external calls wait_to_suspend(), a suspend signal (__suspend_signal Future) 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:

MechanismTypeRecoverableTrigger sourceControllability
Cooperative suspend (wait_to_suspend/resume)Suspend✅ RecoverableExternal, proactiveInternal cooperates
Internal·suppressible (node exception -> Panic)Interrupt✅ Recoverable via RecoverInside nodeSuppressible via Try/CATCH
Internal·unsuppressible (InterruptNotice)Interrupt❌ IrrecoverableInside node / external throwInternally uncontrollable
External Interrupt (call_sub(interrupt=True))InterruptNo "recovery" conceptExternal callerInternally 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: InterruptNotice terminates 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.

Apache 2.0 License