Skip to content

Commit 5cfee19

Browse files
committed
fix(tui): keep ungated sibling elapsed through an unrelated grant
gateOpened snapshotted every timed call into the rebase set, so an auto-allowed sibling that was already executing reset to 0:00 when a later gate settled. Skip ids that already carry a live elapsed clock.
1 parent 02dd9a2 commit 5cfee19

2 files changed

Lines changed: 86 additions & 7 deletions

File tree

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3170,6 +3170,68 @@ describe("CL-7802 gated tool elapsed starts at grant", () => {
31703170
);
31713171
});
31723172

3173+
test("ungated in-flight sibling does not rebase when a later gate settles", async () => {
3174+
await withTestRenderer(
3175+
async (h) => {
3176+
const shell = createAppShell(h.renderer, {
3177+
terminal: { columns: 80, rows: 24 },
3178+
wireKeys: false,
3179+
run: "busy",
3180+
});
3181+
let nowMs = 0;
3182+
let tick: (() => void) | undefined;
3183+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
3184+
now: () => nowMs,
3185+
schedule: (fn) => {
3186+
tick = fn;
3187+
return () => {
3188+
tick = undefined;
3189+
};
3190+
},
3191+
});
3192+
try {
3193+
bridge.handle({ type: "inference.start", data: {} });
3194+
bridge.handle({
3195+
type: "inference.tool_call.end",
3196+
data: { name: "grep", callId: "sibling", arguments: "needle" },
3197+
});
3198+
const siblingIndex = streamRowCount(shell) - 1;
3199+
const siblingStat = () =>
3200+
defined(shell.streamLog[siblingIndex], "sibling row").stat;
3201+
3202+
nowMs = 60_000;
3203+
tick?.();
3204+
await h.renderOnce();
3205+
expect(siblingStat()).toBe("1:00");
3206+
3207+
bridge.handle({
3208+
type: "inference.tool_call.end",
3209+
data: { name: "run_shell", callId: "gated", arguments: "sleep 30" },
3210+
});
3211+
const gatedIndex = streamRowCount(shell) - 1;
3212+
const gatedStat = () =>
3213+
defined(shell.streamLog[gatedIndex], "gated row").stat;
3214+
3215+
bridge.gateOpened();
3216+
bridge.gateClosed();
3217+
await h.renderOnce();
3218+
expect(siblingStat()).toBe("1:00");
3219+
expect(gatedStat()).toBe("0:00");
3220+
3221+
nowMs = 65_000;
3222+
tick?.();
3223+
await h.renderOnce();
3224+
expect(siblingStat()).toBe("1:05");
3225+
expect(gatedStat()).toBe("0:05");
3226+
} finally {
3227+
bridge.dispose();
3228+
shell.dispose();
3229+
}
3230+
},
3231+
{ width: 80, height: 24 },
3232+
);
3233+
});
3234+
31733235
test("diff rows keep their +/- stat through a gate cycle", async () => {
31743236
await withTestRenderer(
31753237
async (h) => {

‎src/tui/runtime-bridge.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -599,11 +599,13 @@ export interface BridgeBag {
599599
*/
600600
toolCallStartedAt: Map<string, number>;
601601
/**
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.
602+
* In-flight ordinary calls waiting on a decision gate. `gateClosed` re-syncs
603+
* their elapsed clocks to the settle so post-grant stats read
604+
* time-since-grant, not time-since-announcement. Auto-allowed siblings that
605+
* already carry a live elapsed clock are executing, not waiting, and stay
606+
* out of the set. `spawn_agent` ids are never rebased — the session clock
607+
* owns those rows. Results and rollbacks drop their ids so the set cannot
608+
* leak.
607609
*/
608610
gatedToolCalls: Set<string>;
609611
/**
@@ -1267,6 +1269,19 @@ function rebaseGatedElapsed(
12671269
}
12681270
}
12691271

1272+
/** `clockLabel` trailer already painted on an in-flight ordinary-tool row. */
1273+
function hasPaintedElapsedClock(
1274+
shell: AppShell,
1275+
bag: BridgeBag,
1276+
callId: string,
1277+
): boolean {
1278+
const index = bag.toolRows.get(callId);
1279+
if (index === undefined) return false;
1280+
const row = bag.pendingRowUpdates.get(index) ?? streamRowAt(shell, index);
1281+
const stat = row?.stat;
1282+
return typeof stat === "string" && /^\d+:\d{2}$/.test(stat);
1283+
}
1284+
12701285
/**
12711286
* User rows the shell paints ahead of the runtime's own inbound copy: a
12721287
* reinject, which lands before the restarted run reports it, and a row the
@@ -2103,9 +2118,11 @@ export function attachSessionBridge(
21032118
const gateOpened = (): void => {
21042119
if (bag.disposed) return;
21052120
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).
2121+
// Snapshot calls waiting on this gate. An auto-allowed sibling that
2122+
// already carries a live elapsed clock is executing, not waiting — leave
2123+
// its startedAt at announcement so post-grant stats stay honest.
21082124
for (const callId of bag.toolCallStartedAt.keys()) {
2125+
if (hasPaintedElapsedClock(shell, bag, callId)) continue;
21092126
bag.gatedToolCalls.add(callId);
21102127
}
21112128
paintPhase();

0 commit comments

Comments
 (0)