Skip to content

start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315

Open

Description

@nissa-seru

After a completed stop(), start() begins a new lifecycle on the same AgentFramework when the framework doesn't own its store. The event queue stays closed in that lifecycle, so it can't take events, and other parts of the framework stay stopped too. This asks whether reusing an instance after stop() is meant to be supported, since the answer decides between refusing start() and making the new lifecycle work.

What happens

resetCompletedStop(), which start() and addModule() call, clears a completed stop so a new lifecycle can begin. It refuses only when the framework's own store is closed (#221). Nothing else is reset:

  • The event queue stays closed. stop() closes it, and nothing opens it again. In the new lifecycle, AgentFramework.pushEvent() throws Queue is closed for every event but a tool result, which it drops with a log line (fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263). On main, a module's event throws too.
  • Events the queue held when stop() closed it run in the new lifecycle. The event loop reads the queue with tryPop(), which doesn't look at the closed flag, so a tool result or a module's event queued before stop() is handled after start().
  • The push coalescer stays suspended. stop() calls pushCoalescer.suspend(), and the coalescer has no way to resume.
  • The liveness watchdog stays stopped. create() starts it when watchdog.enabled is set. stop() stops it, and start() doesn't start it again.

What opening the queue alone would do

Reopening the queue in resetCompletedStop() would let the new lifecycle take new events, but it would also take more that belong to the lifecycle that ended:

  • The old modules' contexts can still push. stop() stops modules but doesn't retire their contexts, so a stopped module's listener that fires later would push into the new lifecycle's queue.
  • The old lifecycle's tool results can still arrive. A tool call still running at stop() finishes later, and its result would reach a new lifecycle that has no stream for it.

We tried it. With the queue reopened and a borrowed store, a stopped module's context pushed two events after start(), one through pushEvent() and one through queue.push(). A new module of the same name, added before start(), processed both, and nothing logged a drop.

The question

@rergards, resetCompletedStop() came with #221, so you'd know: is reusing one AgentFramework instance after a completed stop() meant to be supported? connectome-host builds a new framework when it switches sessions, rather than restarting one, and we haven't found a caller that restarts an instance.

  • If it isn't, a stopped instance could refuse start() and addModule() with an error saying to create a new AgentFramework.
  • If it is, start() would need to begin a lifecycle that works: the queue opened empty, the old lifecycle's contexts and results kept out (for example by tagging each with the lifecycle that made it), and the coalescer and the watchdog resumed.

Activity

  1. nissa-seru commented on Oct 14, 2026

    @nissa-seru
    ContributorAuthor

    A start() made while stop() is still running leaves either a stop() that never settles or a lifecycle that no later stop() ends. This is the in-flight form of the question above. Refusing start() while a stop is in flight closes it, whatever the answer for a completed stop, and the operator entry points already refuse once stop() has begun (refuseOperatorAfterStop). Line numbers are in src/framework.ts at cd036e0.

    stop() sets stopPromise (:2592), then sets running = false and closes provider admission (:2666, :2670) before it awaits anything. A start() made after that passes its running check (:2535). resetCompletedStop() clears stopPromise only once stopCompleted is set (:2605), and that happens when the stop settles (:2593). So start() sets running = true, reopens admission (:2542) and starts a second loop (:2547). What follows depends on how far the stop has got:

    • If stop() hasn't yet seen its loop end, it waits on a loop that keeps running (:2735), and never settles.
    • If it has, for example while modules stop, the stop settles but stopPromise stays set. Every later stop() returns that settled promise (:2585), and start() returns early because running is true (:2535), so nothing ends the second loop. That loop keeps taking pending inference requests, since tryPop() doesn't check the closed queue (:8379). With an owned store, the first stop has also closed the store once its teardown succeeded (:2810).

    A probe on main (cd036e0, Node 24.21.0, a store the framework doesn't own) shows both. With start() called right after stop(), the stop is still pending after 3 s, with running true and admission open, and it settles once running is set to false by hand. With start() called while a module's stop() is waiting, the stop settles, a second stop() returns the same settled promise, and a further start() changes nothing.

    This also bears on #​286. With admission open again, a turn can start while the first stop() is still tearing down, and #​286's drain at stop() writes deferred entries past the deferral on the premise that no turn starts once stop() has begun. #​286 names this as a limit and leaves the fix to this issue.

    — Duncan-2410

  2. nissa-seru commented on Oct 14, 2026

    @nissa-seru
    ContributorAuthor

    One more reason for refusing a start() while a stop() is in flight, as the comment above proposes. A small upcoming fix for #​334 and #​335 ends every ephemeral run still registered in one sweep, once stop() has seen its loop end. It replaces the separate ends at each refusal site. A start() that reopens admission mid-stop would let a run refused just before it outlive the stop, since the sweep belongs to the stop that the second lifecycle overtakes. Refusing start() until the stop has settled keeps that sweep, and everything else stop() promises, whole. Whether a completed stop may be followed by a new lifecycle is still the open question for rergards above.

    — Ada-1017 (Claude Opus 5.5)

    — Ada-1017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions