Skip to content

symfonic.agent.cutover.pause_admission

pause_admission

Whether a pause-capable agent may be served by the kernel typed route (ST3).

HK1 (TA8.34) gave the kernel route a pause: a wired :class:~symfonic.capabilities.human.capability.HumanInteractionCapability folds a pre-tool stage and an ask_user tool into the compiled turn, and :mod:~symfonic.agent.cutover.typed_interrupts renders the stopped run as an AskUserQuestionEvent. HK2 (TA8.35) built the redemption for it. What neither built is the half that connects the second to the public API, and this module exists because ST3's flip is what would otherwise expose the gap.

The gap, stated exactly. A capability pause is minted as a signed envelope and handed to the consumer as whatever string the composition root's encode_token renders it as; the reverse mapping is that transport's, and the engine is never given it. Both public redemption methods -- SymfonicAgent .resume and .resume_interrupt -- decode only the HMAC PauseToken keyed to the framework config, which is a different scheme. So a consumer paused on the kernel typed route holds a token that no public method can redeem: not one that fails a check, one that has no reader.

Why that decides admission rather than being documented. The condition ST3 was given is that flipping with a pause and no resume leaves a human-in-the-loop consumer stopped on a route that cannot continue -- worse than not flipping. The legacy body's pause is redeemable (resume re-enters the graph runtime with a token it minted itself), so keeping such a deployment on _stream_typed_impl is not a degradation; it is the only route where the cycle closes. The turn is counted and attributed as a fallback exactly like any other out-of-envelope turn, so the cutover's fallback rate stays readable.

The predicate is deliberately the same three facts :func:~symfonic.capabilities.human.contribution.build_contribution reads. A capability that contributes nothing cannot stop a run, so it cannot strand one, and refusing it would keep deployments off the kernel route for a pause they could never take.

TA8.45 narrowed this rather than deleting it. The engine now does reach HK2's redemption: SymfonicAgent.resume and .resume_interrupt dispatch invocation.continuation and, when the switch is on and the deployment wired :attr:HumanInteractionCapability.decode_token, continue the paused turn on the kernel. For such a deployment the cycle closes and the reason this module exists is gone — the check returns None and the typed route is served by the kernel.

Both halves of that sentence are asked, not one. The cycle closes only when a reader exists and the continuation is actually routed to the kernel, so :func:unredeemable_pause_reason takes continuation_served_by_kernel as a required keyword rather than inferring it from decode_token. The two facts are independent: invocation.continuation is a live rollback lever (_rolled_back / process_rollback_reason, kept for T4.4.6/RET-11's conformance suite and asserted as a live property by TA8.45's own revert test), and a deployment that wired the reader and then reverted the continuation is back in ST3's condition exactly — the kernel typed route would mint a capability envelope and resume would hand that string to :meth:SymfonicAgent._legacy_continuation_impl, which decodes only the HMAC PauseToken and cannot read it. Reading only decode_token would admit that turn and leave it out of the tally, which is the shape ST3 refuses: stranded, and unmeasured.

What the check must not do is return None for a deployment that wired a pause transport and no reader for it. That agent is exactly the one ST3 described: it can mint an envelope on the kernel typed route and no public method can turn the consumer's string back into one. Deleting the check outright would have stranded it, which is why the narrowing is to the missing seam rather than to the task that added the seam. The two tests aimed at this survive with it — they wire no decode_token, which is the state they were written about.

unredeemable_pause_reason

unredeemable_pause_reason(capability: Any, *, continuation_served_by_kernel: bool) -> str | None

NO_PUBLIC_REDEMPTION if this capability could strand a turn, else None.

None for the overwhelming majority of agents, whose composition root wired no pause transport at all and whose dispatch is unchanged by this check — and, since TA8.45, None for a deployment that wired the reader as well as the writer and still routes its continuation to the kernel, because the public redemption door opens onto the kernel continuation for exactly that deployment.

Parameters:

Name Type Description Default
capability Any

The agent's wired human-interaction capability, or None.

required
continuation_served_by_kernel bool

Whether invocation.continuation currently routes to :class:~symfonic.agent.cutover.Route .KERNEL. Required rather than defaulted, and keyword-only, so no call site can close the cycle on the writer alone: a reverted continuation sends a capability-minted token to the legacy body, which has no reader for it.

required
Source code in src/symfonic/agent/cutover/pause_admission.py
def unredeemable_pause_reason(
    capability: Any, *, continuation_served_by_kernel: bool
) -> str | None:
    """``NO_PUBLIC_REDEMPTION`` if this capability could strand a turn, else ``None``.

    ``None`` for the overwhelming majority of agents, whose composition root
    wired no pause transport at all and whose dispatch is unchanged by this
    check — and, since TA8.45, ``None`` for a deployment that wired the reader
    as well as the writer *and* still routes its continuation to the kernel,
    because the public redemption door opens onto the kernel continuation for
    exactly that deployment.

    Args:
        capability: The agent's wired human-interaction capability, or ``None``.
        continuation_served_by_kernel: Whether ``invocation.continuation``
            currently routes to :class:`~symfonic.agent.cutover.Route`
            ``.KERNEL``. Required rather than defaulted, and keyword-only, so
            no call site can close the cycle on the writer alone: a reverted
            continuation sends a capability-minted token to the legacy body,
            which has no reader for it.
    """
    if capability is None:
        return None
    # The seam TA8.45 added, and the route that seam is only half of. A
    # transport that can render a minted envelope as a token *and* recover it
    # from the string a consumer presents has a closed cycle **while the
    # continuation is served by the kernel**: ``SymfonicAgent.resume``
    # recognises the token, redeems it through HK2's one preparation and
    # continues the turn. Revert the continuation switch and the same
    # deployment strands again -- so both facts are asked here, together.
    # Read before the three facts below because they settle the question those
    # are asked for: the three decide whether a pause can happen at all, and
    # this decides whether one that happens can be answered.
    if (
        continuation_served_by_kernel
        and getattr(capability, "decode_token", None) is not None
    ):
        return None
    # The three facts, in the order ``build_contribution`` reads them: nothing
    # registered, no per-run binding resolver, or no way to render a minted
    # envelope as a token. Any one of them and the capability contributes only
    # its name -- no stage, no ``ask_user`` -- so the run cannot stop here.
    if not getattr(capability, "active", False):
        return None
    if getattr(capability, "binding", None) is None:
        return None
    if getattr(capability, "encode_token", None) is None:
        return None
    return NO_PUBLIC_REDEMPTION