You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315
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:
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.
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.
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.
Activity
nissa-seru commentedon Oct 14, 2026
A
start()made whilestop()is still running leaves either astop()that never settles or a lifecycle that no laterstop()ends. This is the in-flight form of the question above. Refusingstart()while a stop is in flight closes it, whatever the answer for a completed stop, and the operator entry points already refuse oncestop()has begun (refuseOperatorAfterStop). Line numbers are insrc/framework.tsatcd036e0.stop()setsstopPromise(:2592), then setsrunning = falseand closes provider admission (:2666,:2670) before it awaits anything. Astart()made after that passes itsrunningcheck (:2535).resetCompletedStop()clearsstopPromiseonly oncestopCompletedis set (:2605), and that happens when the stop settles (:2593). Sostart()setsrunning = true, reopens admission (:2542) and starts a second loop (:2547). What follows depends on how far the stop has got:stop()hasn't yet seen its loop end, it waits on a loop that keeps running (:2735), and never settles.stopPromisestays set. Every laterstop()returns that settled promise (:2585), andstart()returns early becauserunningis true (:2535), so nothing ends the second loop. That loop keeps taking pending inference requests, sincetryPop()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. Withstart()called right afterstop(), the stop is still pending after 3 s, withrunningtrue and admission open, and it settles oncerunningis set to false by hand. Withstart()called while a module'sstop()is waiting, the stop settles, a secondstop()returns the same settled promise, and a furtherstart()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 atstop()writes deferred entries past the deferral on the premise that no turn starts oncestop()has begun. #286 names this as a limit and leaves the fix to this issue.— Duncan-2410
docs(deferral): say what boot drops and why, and that start() during …
nissa-seru commentedon Oct 14, 2026
One more reason for refusing a
start()while astop()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, oncestop()has seen its loop end. It replaces the separate ends at each refusal site. Astart()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. Refusingstart()until the stop has settled keeps that sweep, and everything elsestop()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
fix(diagnostics): one SIGUSR2 listener per process, dumping each runn…
fix(diagnostics): the internal review's round on the shared SIGUSR2 l…
fix(diagnostics): one SIGUSR2 listener per process, dumping each runn…
fix(diagnostics): the internal review's round on the shared SIGUSR2 l…
fix(stop): end every ephemeral run still registered once the loop has…
docs(stop): the sweep's comment says a start() during a stop() is #315's
fix(framework): refuse start() and addModule() while a stop() is stil…
fix(operator): the alerts' outcome and remedy fit the webhook's head,…