@@ -598,6 +598,14 @@ export interface BridgeBag {
598598 * length of a slow call — the one case a healthy turn reads as dead.
599599 */
600600 toolCallStartedAt : Map < string , number > ;
601+ /**
602+ * In-flight ordinary calls announced before (or while) a decision gate
603+ * stood open. `gateClosed` re-syncs their elapsed clocks to the settle so
604+ * post-grant stats read time-since-grant, not time-since-announcement.
605+ * `spawn_agent` ids are never rebased — the session clock owns those rows.
606+ * Results and rollbacks drop their ids so the set cannot leak.
607+ */
608+ gatedToolCalls : Set < string > ;
601609 /**
602610 * Last live shell tail painted per in-flight call, so an unchanged feed
603611 * snapshot applies no row update.
@@ -1027,6 +1035,9 @@ function applyToolCall(
10271035 // their own) pick up the live timer.
10281036 if ( row . stat === undefined ) {
10291037 bag . toolCallStartedAt . set ( event . callId , bag . now ( ) ) ;
1038+ // Announced while a gate stands open: the wait belongs to the gate, so
1039+ // the settle re-syncs this clock (see gateClosed).
1040+ if ( bag . turn . blockedGateCount > 0 ) bag . gatedToolCalls . add ( event . callId ) ;
10301041 }
10311042 }
10321043 if ( event . callId !== undefined && event . name === SPAWN_AGENT_TOOL_NAME ) {
@@ -1064,6 +1075,7 @@ function applyToolResult(
10641075 if ( event . callId !== undefined ) {
10651076 bag . toolRows . delete ( event . callId ) ;
10661077 bag . toolCallStartedAt . delete ( event . callId ) ;
1078+ bag . gatedToolCalls . delete ( event . callId ) ;
10671079 bag . shellSnapshots . delete ( event . callId ) ;
10681080 // spawn_agent's immediate running JSON is not the end of the worker —
10691081 // keep the row in taskCallIds / spawnProgressRows until the session
@@ -1218,6 +1230,43 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
12181230 }
12191231}
12201232
1233+ /**
1234+ * Re-sync every gate-waited call's elapsed clock to the settle: execution
1235+ * starts at the grant, not at the announcement that preceded the approval
1236+ * wait. Later ticks read time-since-grant through the same syncToolElapsed
1237+ * path an ungated row uses. Fires on every settled gate — allow and deny
1238+ * alike, the only settle signal the gate wiring reports (see gate-wire
1239+ * `onceClosed`) — so deny needs no special case: the result merge already
1240+ * drops the clock-owned stat for the answer's own addendum. Nested gates
1241+ * rebase uniformly at each settle rather than per gate/call pair: the bridge
1242+ * sees gate lifecycles but not which grant covers which call. `shell`
1243+ * keeps its announcement-stamped `inFlightTool.startedAt` on purpose: the
1244+ * only reader (`resolveWaitingOn`) uses it as a steer-wait threshold, not an
1245+ * execution clock, and a steer queued mid-gate has still been waiting.
1246+ */
1247+ function rebaseGatedElapsed (
1248+ shell : AppShell ,
1249+ bag : BridgeBag ,
1250+ nowMs : number ,
1251+ ) : void {
1252+ if ( bag . gatedToolCalls . size === 0 ) return ;
1253+ const grant = clockLabel ( 0 ) ;
1254+ for ( const callId of bag . gatedToolCalls ) {
1255+ bag . gatedToolCalls . delete ( callId ) ;
1256+ // The session clock owns spawn_agent rows; diff rows never enter the
1257+ // gated set (they carry no elapsed clock to rebase).
1258+ if ( bag . taskCallIds . has ( callId ) ) continue ;
1259+ if ( ! bag . toolCallStartedAt . has ( callId ) ) continue ;
1260+ bag . toolCallStartedAt . set ( callId , nowMs ) ;
1261+ const index = bag . toolRows . get ( callId ) ;
1262+ if ( index === undefined ) continue ;
1263+ const row = bag . pendingRowUpdates . get ( index ) ?? streamRowAt ( shell , index ) ;
1264+ if ( row === undefined || row . pending !== true ) continue ;
1265+ if ( row . stat === grant ) continue ;
1266+ rowUpdates . scheduleRowUpdate ( bag , index , { ...row , stat : grant } ) ;
1267+ }
1268+ }
1269+
12211270/**
12221271 * User rows the shell paints ahead of the runtime's own inbound copy: a
12231272 * reinject, which lands before the restarted run reports it, and a row the
@@ -1256,6 +1305,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void {
12561305 if ( index >= boundary ) {
12571306 bag . toolRows . delete ( callId ) ;
12581307 bag . toolCallStartedAt . delete ( callId ) ;
1308+ bag . gatedToolCalls . delete ( callId ) ;
12591309 bag . taskCallIds . delete ( callId ) ;
12601310 bag . spawnProgressRows . delete ( callId ) ;
12611311 bag . shellSnapshots . delete ( callId ) ;
@@ -1533,6 +1583,7 @@ export function attachSessionBridge(
15331583 now,
15341584 toolRows : new Map ( ) ,
15351585 toolCallStartedAt : new Map ( ) ,
1586+ gatedToolCalls : new Set ( ) ,
15361587 shellSnapshots : new Map ( ) ,
15371588 lastToolRow : - 1 ,
15381589 taskCallIds : new Set ( ) ,
@@ -2052,12 +2103,20 @@ export function attachSessionBridge(
20522103 const gateOpened = ( ) : void => {
20532104 if ( bag . disposed ) return ;
20542105 bag . turn = turnStateGateOpened ( bag . turn ) ;
2106+ // Snapshot every timed call: each one waits out this gate, so the settle
2107+ // re-syncs their clocks (see gateClosed).
2108+ for ( const callId of bag . toolCallStartedAt . keys ( ) ) {
2109+ bag . gatedToolCalls . add ( callId ) ;
2110+ }
20552111 paintPhase ( ) ;
20562112 } ;
20572113
20582114 const gateClosed = ( ) : void => {
20592115 if ( bag . disposed ) return ;
20602116 bag . turn = turnStateGateClosed ( bag . turn , now ( ) ) ;
2117+ // The grant is execution start: waited clocks re-sync here so post-grant
2118+ // stats read time-since-grant (see rebaseGatedElapsed).
2119+ rebaseGatedElapsed ( shell , bag , now ( ) ) ;
20612120 paintPhase ( ) ;
20622121 flushOccupancyThenWake ( ) ;
20632122 } ;
0 commit comments