fix(desktop): a status read lost to the network is asked again, so a pane nobody is looking at stops reading as running

This commit is contained in:
esengine committed 2026-10-17 05:04:26 -07:00
1 parent 022b82d2d5
commit 7b726d70fa
5 files changed
+132 -1

No files matched your search

+8
View File
@@ -95,6 +95,14 @@ try {
const replies = (text.match(new RegExp(`word${WORDS - 1}`, "g")) ?? []).length;
check("the open pane shows every reply it was sent while the network flapped", replies >= TURNS + 1, `${replies} of ${TURNS + 1} replies`);
check("the open pane returns to idle", done);
// The sidebar counts what is running from the page's own state of every pane,
// open or not; the Studio, the shared stream's metrics and the tree already agree
// that nothing runs, so a count that stays up is the page not recovering.
const live = () => page.evaluate(() => document.querySelector('.studio-session-segments button[data-value="live"] b')?.textContent?.trim() ?? "0");
const quiet = await until("the sidebar's running count to reach zero", async () => (await live()) === "0", 45_000).then(() => true, () => false);
const stuck = quiet ? "" : await page.evaluate(() => [...document.querySelectorAll(".sessrow[data-run]")].map((r) => `${r.textContent.trim().slice(0, 18)}:${r.getAttribute("data-run")}`).join(" | "));
check("the sidebar's running count settles to zero once every turn has finished", quiet, quiet ? "" : `reads ${await live()}; rows ${stuck}`);
if (process.env.RELAY_SHOT) {
await page.mouse.move(700, 700);
await page.waitForTimeout(2500);
+2 -1
View File
@@ -61,6 +61,7 @@ import { SidePanel } from "./SidePanel";
import { cancelJob } from "./jobcancel";
import { ConnectNote } from "./ConnectNote";
import { useStatusPoll } from "./useStatusPoll";
import { useStatusRefresh } from "./useStatusRefresh";
import { useCheckpointRefresh } from "./useCheckpointRefresh";
import { LiveWork, useLiveWork } from "../state/foldpref";
import { loadChangedFilesPref, useShowsChangedFiles } from "../state/changedfiles";
@@ -180,7 +181,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
}, []);
const readJob = useCallback((id: string) => port.jobOutput(id), [port]);
const refreshStatus = useCallback(() => port.status().then((st) => { applyStatus(st); statusRead(); }).catch(() => {}), [port, applyStatus, statusRead]);
const refreshStatus = useStatusRefresh(port, applyStatus, statusRead);
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
const hideAmounts = useHidesAmounts();
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import "./testkit";
import { MockPort } from "../port/mock";
import type { WireEvent } from "../port/wire";
import { Pane, type PaneReport } from "./Pane";
afterEach(() => { cleanup(); vi.useRealTimers(); });
// A pane the person is not looking at. Its state of running comes from two
// reads of the kernel — the frames of its stream, and the status snapshot a turn
// boundary asks for — and when the network drops under the second one the pane
// has no other way to hear that the turn ended.
async function mount() {
const port = new MockPort();
const snapshot = await port.status();
const kernel = { running: false, failing: 0, reads: 0 };
vi.spyOn(port, "status").mockImplementation(async () => {
kernel.reads++;
if (kernel.failing > 0) {
kernel.failing--;
throw new TypeError("network down");
}
return { ...snapshot, running: kernel.running };
});
let deliver: ((event: WireEvent) => void) | undefined;
vi.spyOn(port, "subscribe").mockImplementation((onEvent) => {
deliver = onEvent;
return () => { deliver = undefined; return true; };
});
const onReport = vi.fn<(id: string, report: PaneReport) => void>();
const view = render(
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath: "/sessions/a.jsonl" }} title="a" active={false}
visible={false} sideHost={null} side={false} onFocus={() => {}} onReport={onReport} onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}}
needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}} theme="light" dockW={320} dockMax={640} onDockW={() => {}} />,
);
const send = (kind: WireEvent["kind"]) => act(() => deliver?.({ kind }));
const lastLive = () => onReport.mock.calls.at(-1)?.[1].live;
return { kernel, send, onReport, lastLive, view };
}
it("hears that a turn ended even when the read that confirms it fails", async () => {
const pane = await mount();
pane.kernel.running = true;
await pane.send("turn_started");
await waitFor(() => expect(pane.lastLive()).toBe(true));
pane.kernel.running = false;
pane.kernel.failing = 1;
await pane.send("turn_done");
await waitFor(() => expect(pane.lastLive()).toBe(false), { timeout: 4000 });
});
it("keeps asking while the network stays down, and not in a tight loop", async () => {
const pane = await mount();
pane.kernel.running = true;
await pane.send("turn_started");
await waitFor(() => expect(pane.lastLive()).toBe(true));
pane.kernel.running = false;
pane.kernel.failing = 3;
const before = pane.kernel.reads;
await pane.send("turn_done");
await waitFor(() => expect(pane.lastLive()).toBe(false), { timeout: 9000 });
const asked = pane.kernel.reads - before;
expect(asked).toBeGreaterThanOrEqual(4);
expect(asked).toBeLessThan(8);
}, 15000);
it("stops asking when the pane goes away", async () => {
const pane = await mount();
pane.kernel.running = true;
await pane.send("turn_started");
await waitFor(() => expect(pane.lastLive()).toBe(true));
pane.kernel.failing = 100;
await pane.send("turn_done");
pane.view.unmount();
const at = pane.kernel.reads;
await new Promise((resolve) => setTimeout(resolve, 2500));
expect(pane.kernel.reads).toBe(at);
}, 10000);
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef } from "react";
import type { AgentPort, SessionStatus } from "../port/port";
const FIRST_MS = 1000;
const CAP_MS = 15_000;
/** One way to ask the kernel for a pane's status that does not give up. A turn
* boundary is the moment the snapshot is asked for, and the snapshot is what
* closes or vouches for a run, so a read lost to the network leaves the pane
* believing what it last heard — for a pane nobody is looking at, nothing else
* asks again. A failed read is therefore asked again with growing gaps until it
* answers, and stops when the pane goes away. */
export function useStatusRefresh(port: AgentPort, adopt: (status: SessionStatus) => void, settled: () => void) {
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const tries = useRef(0);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
clearTimeout(timer.current);
tries.current = 0;
};
}, [port]);
const refresh = useCallback((): Promise<void> => port.status().then(
(status) => {
clearTimeout(timer.current);
tries.current = 0;
adopt(status);
settled();
},
() => {
if (!alive.current) return;
clearTimeout(timer.current);
const gap = Math.min(CAP_MS, FIRST_MS * 2 ** tries.current++);
timer.current = setTimeout(() => void refresh(), gap);
},
), [port, adopt, settled]);
return refresh;
}
@@ -0,0 +1,2 @@
- 修复:断网恢复后,侧栏“进行中”计数不再卡在已经结束的会话上(没打开的窗格也会在网络恢复后重新读到状态)。关联 #13604。
- Fixed: after a network flap the sidebar's running count no longer sticks on conversations that finished; a pane you have not opened reads its status again once the network is back. Refs #13604.