Skip to content

Cancellation

vega-core uses cooperative cancellation at stage boundaries and forceful cancellation at external agent boundaries. Stages never terminate subprocesses directly — they call framework checkpoints, and the runner layer handles active agent cancellation.

Location

src/services/cancellation.py

CancellationToken protocol

The caller (vega-backend) supplies a cancellation signal:

class CancellationToken(Protocol):
    def is_cancelled(self) -> bool: ...

Pass it to configure_core_runtime(cancellation_token=token). Any object implementing .is_cancelled() works — a threading event wrapper, a database polling token, etc.

CancellationManager

CancellationManager is the framework's central cancellation hub. It wraps the caller-supplied token and adds coordination:

class CancellationManager:
    def __init__(self, token: CancellationToken | None = None) -> None: ...

    def is_cancelled(self) -> bool: ...

    def checkpoint(
        self,
        *,
        stage: StageName,
        progress: Progress | None = None,
    ) -> None: ...

    def register_active_agent_call(self, call_id: str, cancel: CancelFn) -> None: ...
    def unregister_active_agent_call(self, call_id: str) -> None: ...
    def cancel_active_agent_calls(self) -> None: ...

CancellationManager is available through RunContext.cancellation. Stages and BaseStage call context.checkpoint(stage, progress) at natural boundaries.

StageCancelled exception

class StageCancelled(Exception):
    stage: StageName
    progress: Progress | None

checkpoint(...) raises StageCancelled when is_cancelled() is true. BaseStage.run(...) catches this, calls EventBus.stage_cancelled(...), and returns a StageOutput(status=StageStatus.CANCELLED).

How BaseStage uses cancellation

BaseStage.run(...) calls checkpoints automatically:

1. checkpoint(stage) — before calling execute(...)
2. execute(context, input) runs
3. checkpoint(stage, progress) — after execute returns (or per-item in loops)

Concrete stages that process multiple items (audit components, bugs) call context.checkpoint(stage, progress) in their inner loop — between items, not inside agent calls.

How AgentRunner uses cancellation

AgentRunner registers each active agent call with CancellationManager.register_active_agent_call(call_id, cancel_fn). The cancel_fn sends SIGTERM (then SIGKILL) to the subprocess.

When checkpoint(...) detects cancellation, it first calls cancel_active_agent_calls() to immediately terminate any in-flight agent subprocess, then raises StageCancelled.

Cancellation sequence (backend → core)

vega-backend sets cancellation_token.is_cancelled() → True
  ↓
CancellationManager.checkpoint() detects it
  ↓
CancellationManager.cancel_active_agent_calls()
  → CodexAdapter.cancel(task_id) → SIGTERM → SIGKILL
  ↓
StageCancelled raised
  ↓
BaseStage catches it → emits stage_cancelled event
  ↓
StageOrchestrator stops the sequence
  ↓
CoreResult(status="cancelled") returned to vega-backend

Checkpoint placement per stage

Stage Checkpoint placement
PlanStage Before and after agent call
BaseThreatModelStage Before and after agent call
BaseAuditStage Between each component; before and after each component agent call
VerifyStage Between each bug; before and after each bug agent call
PatchStage Between each finding; between patch pipeline steps per finding

Implementing a cancellation token

Thread-event token (local dev)

import threading

class ThreadCancellationToken:
    def __init__(self):
        self._event = threading.Event()

    def cancel(self):
        self._event.set()

    def is_cancelled(self) -> bool:
        return self._event.is_set()

Database polling token (production)

class DatabaseCancellationToken:
    def __init__(self, scan_id: str, db):
        self._scan_id = scan_id
        self._db = db

    def is_cancelled(self) -> bool:
        return self._db.query(
            "SELECT cancelled FROM scans WHERE id = %s", (self._scan_id,)
        )

Pass the token at run configuration time:

configure_core_runtime(
    event_sink=my_sink,
    cancellation_token=DatabaseCancellationToken(scan_id, db),
)

Graceful vs forceful cancellation

  • Cooperative (stages): checkpoint(...) raises at a natural boundary. Ongoing work finishes cleanly before the exception is raised.
  • Forceful (agents): cancel_active_agent_calls() sends OS signals to subprocesses. This is immediate but can leave partial output.

The CoreResult returned for a cancelled run has status="cancelled". Backend should update the scan status accordingly and not treat it as a failure.