@@ -252,15 +252,15 @@ export interface SessionBridge {
252252 beginSystemContinuation : ( text : string ) => void ;
253253 /**
254254 * Occupancy send failed after beginSystemContinuation. Drop the occupancy
255- * echo so a later matching inbound is not swallowed, re-arm the dry-open
255+ * echo so a later matching inbound is not swallowed, re-arm the dry-episode
256256 * latch, drop the continuation hold, and idle so follow-ups can drain and a
257257 * later settle can take another occupancy shot.
258258 */
259259 abortSystemContinuation : ( ) => void ;
260260 /**
261- * Occupancy owner for dry+open continuation. Called once from
262- * settleRunToIdle when a latched fleet-dry edge is still dry. Return true
263- * if a continuation was sent (run stays busy).
261+ * Occupancy owner for dry+open continuation. Called once per dry episode
262+ * from settleRunToIdle when the fleet is dry. Return true if a continuation
263+ * was sent (run stays busy).
264264 */
265265 setDryOpenTaskDriver : ( driver : ( ( ) => boolean ) | undefined ) => void ;
266266}
@@ -425,18 +425,19 @@ export interface BridgeBag {
425425 */
426426 flushPendingAskWake : ( ( ) => void ) | null ;
427427 /**
428- * One deferred occupancy shot for the last live-fleet → 0 edge. Consumed on
429- * settle so a missed wentDry while the parent was processing still drives
430- * once, and a later settle cannot loop.
428+ * One occupancy shot per dry episode. Reset when a live lane starts. Consumed
429+ * only when the driver actually sends a continuation — a no-op (no open
430+ * tasks) must not eat the shot, or a later missed 1→0 with leftover tasks
431+ * never drives. A later settle after a true drive cannot loop.
431432 */
432- pendingDryOpenDrive : boolean ;
433+ droveOpenTasksThisDry : boolean ;
433434 /**
434435 * beginSystemContinuation re-armed the turn during the previous cycle's
435436 * settle. Late connector.reply from that cycle must not settle this one
436437 * until its own inference.start arrives.
437438 */
438439 awaitingContinuationInference : boolean ;
439- /** Occupancy driver: collect+send when settle takes the deferred dry shot. */
440+ /** Occupancy driver: collect+send when settle takes a dry-episode shot. */
440441 dryOpenTaskDriver : ( ( ) => boolean ) | undefined ;
441442 /** Last prompt actually sent — replay source for the quota auto-retry. */
442443 lastSentMessage : string ;
@@ -1011,19 +1012,17 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void {
10111012 }
10121013}
10131014
1014- function occupancyHold ( bag : BridgeBag , runBusy : boolean ) : boolean {
1015- if ( bag . liveFleet > 0 || bag . awaitingContinuationInference ) return true ;
1016- return runBusy && bag . pendingDryOpenDrive ;
1015+ function occupancyHold ( bag : BridgeBag ) : boolean {
1016+ return bag . liveFleet > 0 || bag . awaitingContinuationInference ;
10171017}
10181018
10191019/**
10201020 * Release the run to idle and drain everything queued — but only at true
10211021 * session-idle. A live fleet holds the run busy after the parent turn settles
10221022 * (idle-with-fleet): Enter upgrades to a new primary turn during the hold and
10231023 * follow-ups keep waiting; the fleet event landing at zero re-enters here to
1024- * release the hold. A latched dry edge with open tasks takes one occupancy
1025- * shot here instead of idling, so a wentDry missed while processing cannot
1026- * disagree with settle.
1024+ * release the hold. A dry fleet takes one occupancy shot here per dry episode
1025+ * instead of idling, even if the live 1→0 edge was never observed.
10271026 */
10281027function settleRunToIdle ( shell : AppShell , bag : BridgeBag ) : void {
10291028 if ( shell . session . run !== "busy" ) return ;
@@ -1041,15 +1040,19 @@ function settleRunToIdle(shell: AppShell, bag: BridgeBag): void {
10411040 bag . flushPendingAskWake ?.( ) ;
10421041 return ;
10431042 }
1044- if ( bag . pendingDryOpenDrive ) {
1045- bag . pendingDryOpenDrive = false ;
1043+ if ( ! bag . droveOpenTasksThisDry ) {
10461044 let driven = false ;
10471045 try {
10481046 driven = bag . dryOpenTaskDriver ?.( ) === true ;
10491047 } catch {
10501048 driven = false ;
10511049 }
1052- if ( driven ) return ;
1050+ // Consume only on a real continuation. A false/no-op leaves the latch
1051+ // open so a later missed-edge settle with open tasks can still fire.
1052+ if ( driven ) {
1053+ bag . droveOpenTasksThisDry = true ;
1054+ return ;
1055+ }
10531056 }
10541057 shell . session = setRunState ( shell . session , "idle" ) ;
10551058 bag . awaitingContinuationInference = false ;
@@ -1074,12 +1077,9 @@ function applyInbound(
10741077 // queued follow-ups drain now. While the parent is still working the
10751078 // count just updates — the ordinary turn settle does the draining.
10761079 if ( event . type === "fleet" ) {
1077- const previous = bag . liveFleet ;
10781080 bag . liveFleet = event . running ;
10791081 if ( event . running > 0 ) {
1080- bag . pendingDryOpenDrive = false ;
1081- } else if ( previous > 0 ) {
1082- bag . pendingDryOpenDrive = true ;
1082+ bag . droveOpenTasksThisDry = false ;
10831083 }
10841084 if ( event . running === 0 && ! bag . turn . isProcessing ) {
10851085 settleRunToIdle ( shell , bag ) ;
@@ -1185,7 +1185,7 @@ export function attachSessionBridge(
11851185 pendingAskWake : new Map ( ) ,
11861186 deliveredAskWake : new Map ( ) ,
11871187 flushPendingAskWake : null ,
1188- pendingDryOpenDrive : false ,
1188+ droveOpenTasksThisDry : false ,
11891189 awaitingContinuationInference : false ,
11901190 dryOpenTaskDriver : undefined ,
11911191 lastSentMessage : "" ,
@@ -1292,7 +1292,7 @@ export function attachSessionBridge(
12921292 currentToolName : turn . currentToolName ,
12931293 streamingType : turn . streamingType ,
12941294 nowMs,
1295- sessionActive : occupancyHold ( bag , shell . session . run === "busy" ) ,
1295+ sessionActive : occupancyHold ( bag ) ,
12961296 } ;
12971297 const fleet = fleetProgress ( bag . agentSessions , nowMs ) ;
12981298 const label = resolveTurnLabel ( input , isStalled , fleet ) ;
@@ -1573,7 +1573,7 @@ export function attachSessionBridge(
15731573 bag . liveFleet = 0 ;
15741574 bag . pendingAskWake . clear ( ) ;
15751575 bag . deliveredAskWake . clear ( ) ;
1576- bag . pendingDryOpenDrive = false ;
1576+ bag . droveOpenTasksThisDry = false ;
15771577 bag . awaitingContinuationInference = false ;
15781578 bag . pendingRowUpdates . clear ( ) ;
15791579 paintChrome ( shell ) ;
@@ -1746,7 +1746,7 @@ export function attachSessionBridge(
17461746 }
17471747 }
17481748 bag . awaitingContinuationInference = false ;
1749- bag . pendingDryOpenDrive = true ;
1749+ bag . droveOpenTasksThisDry = false ;
17501750 bag . lastSentMessage = "" ;
17511751 flushOpenRow ( shell , bag ) ;
17521752 bag . turnThinking = null ;
0 commit comments