Skip to content

Commit 02dd9a2

Browse files
committed
fix(tui): start gated tool elapsed at grant, not announcement
1 parent d728cd6 commit 02dd9a2

1 file changed

Lines changed: 59 additions & 0 deletions

File tree

‎src/tui/runtime-bridge.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)