Skip to content

symfonic.kernel.run_observation

run_observation

What a runner opens around one turn so the turn can be observed.

Two things belong to the run rather than to any step of it: the identity every observer correlates on, and the stage traces the run publishes as its capabilities execute. They were two modules of about twenty lines each, and the runner had to remember to open one, thread run_id and the trace sink through five call sites, and close the other in a finally.

One object owns both now. The runner opens it, hands it the traces each phase produced, and closes it -- and the correlation identifier is never passed again, because the thing publishing already knows which run it is.

Metadata only, and that is a contract not a convenience. A stage trace carries the stage's name, phase, capability and outcome. It never carries the subject a stage acted on or the payload it produced: this stream reaches durable storage and an operator's screen, and a memory's text on either would be a leak that no consumer could opt out of.

Emitter

Emitter(run_id: str)

Assign event identity, ordering, and terminal cardinality once (RCX-6).

Source code in src/symfonic/kernel/run_observation.py
def __init__(self, run_id: str) -> None:
    self._index = 0
    self._run_id = run_id
    self._closed = False

RunObservation

RunObservation(context: Any, request: Any, callbacks: Any, emitter: Any = None)

The identity a run carries, and the traces it publishes.

Parameters:

Name Type Description Default
context Any

the invocation context, which owns run_id.

required
request Any

the turn request, which may name a root and a parent when this run is a delegated child.

required
callbacks Any

unused now that stage events ride the ordinary channel; kept so the signature still says what a run is given.

required
emitter Any

the run's event factory. Stage events take their index from it, so they sit in the same dense sequence as the model and tool events and a reader can reconstruct the turn in order. They were emitted at index=-1 before this, which is not a position -- two of them could not be told apart, and neither could be placed against the round it belonged to.

None
Source code in src/symfonic/kernel/run_observation.py
def __init__(
    self, context: Any, request: Any, callbacks: Any, emitter: Any = None
) -> None:
    """
    Args:
        context: the invocation context, which owns ``run_id``.
        request: the turn request, which may name a root and a parent when
            this run is a delegated child.
        callbacks: unused now that stage events ride the ordinary
            channel; kept so the signature still says what a run is given.
        emitter: the run's event factory. Stage events take their index
            from it, so they sit in the same dense sequence as the model
            and tool events and a reader can reconstruct the turn in
            order. They were emitted at ``index=-1`` before this, which
            is not a position -- two of them could not be told apart, and
            neither could be placed against the round it belonged to.
    """
    self._run_id = context.run_id
    self._emitter = emitter
    self._token = push_run_identity(
        RunIdentity(
            run_id=context.run_id,
            root_run_id=request.root_run_id or context.run_id,
            parent_run_id=request.parent_run_id,
        )
    )

close

close() -> None

Release the identity. Idempotent, because teardown has two paths.

Source code in src/symfonic/kernel/run_observation.py
def close(self) -> None:
    """Release the identity. Idempotent, because teardown has two paths."""
    if self._token is not None:
        reset_run_identity(self._token)
        self._token = None

stage_events

stage_events(traces: tuple[Any, ...]) -> tuple[Any, ...]

One phase's stage records, as events on the run's own sequence.

Returned rather than published, because the caller is an async generator and these have to be yielded: the public stream and the durable execution log must be two readers of one event, and an event delivered only to callbacks is a second channel that can disagree with the first about what a turn did.

Indices come from the run's emitter, so a stage sits in order among the model and tool events and a reader can place a retrieval against the round it fed. They were emitted at index=-1 before this, which is not a position: two of them could not be told apart.

Source code in src/symfonic/kernel/run_observation.py
def stage_events(self, traces: tuple[Any, ...]) -> tuple[Any, ...]:
    """One phase's stage records, as events on the run's own sequence.

    Returned rather than published, because the caller is an async
    generator and these have to be *yielded*: the public stream and the
    durable execution log must be two readers of one event, and an event
    delivered only to callbacks is a second channel that can disagree
    with the first about what a turn did.

    Indices come from the run's emitter, so a stage sits in order among
    the model and tool events and a reader can place a retrieval against
    the round it fed. They were emitted at ``index=-1`` before this, which
    is not a position: two of them could not be told apart.
    """
    if self._emitter is None:
        return ()
    events = []
    for trace in traces:
        try:
            events.append(
                self._emitter.make(
                    "stage",
                    stage_id=trace.stage_id,
                    phase=str(getattr(trace.phase, "value", trace.phase)),
                    capability=trace.capability,
                    stage_outcome=str(
                        getattr(trace.outcome, "value", trace.outcome)
                    ),
                    stage_reason=trace.reason,
                    counts=trace.counts,
                )
            )
        except ContractViolationError:
            # The stream went terminal first. A stage record is worth
            # having and never worth reopening a closed stream for.
            break
    return tuple(events)

terminal_on_exit async

terminal_on_exit(lifecycle: Any, emitter: Emitter, publish: Any, reason: str) -> None

Record — and where owed, deliver — the cancelled terminal (CXL-2).

Cancellation is not an error, so it never emits an error event. If the stream already went terminal the emitter refuses a second one (EVT-1), and there is nothing left to say.

reason is the run's own teardown word — disconnected when the consumer walked away, cancelled when this task was cancelled — and it rides on the terminal so an observer can tell the two apart. Both are kind="cancelled": they end the stream the same way, and they mean different things about who stopped it (TA8.29).

Source code in src/symfonic/kernel/run_observation.py
async def terminal_on_exit(
lifecycle: Any, emitter: Emitter, publish: Any, reason: str
) -> None:
    """Record — and where owed, deliver — the cancelled terminal (CXL-2).

    Cancellation is not an error, so it never emits an ``error`` event. If
    the stream already went terminal the emitter refuses a second one
    (EVT-1), and there is nothing left to say.

    ``reason`` is the run's own teardown word — ``disconnected`` when the
    consumer walked away, ``cancelled`` when this task was cancelled — and
    it rides on the terminal so an observer can tell the two apart. Both
    are ``kind="cancelled"``: they end the stream the same way, and they
    mean different things about who stopped it (TA8.29).
    """
    try:
        cancelled = emitter.make("cancelled", termination=reason)
    except ContractViolationError:
        return
    await lifecycle.deliver_terminal(cancelled, publish)