diff --git a/packages/ui/src/stores/connection-resync-gate.test.ts b/packages/ui/src/stores/connection-resync-gate.test.ts new file mode 100644 index 00000000..aaa5fef1 --- /dev/null +++ b/packages/ui/src/stores/connection-resync-gate.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { ConnectionResyncGate } from "./connection-resync-gate.ts" + +describe("connection resync gate", () => { + it("skips initial connection and resyncs once after a stream failure", () => { + const gate = new ConnectionResyncGate() + assert.equal(gate.observe("instance", "connecting"), false) + assert.equal(gate.observe("instance", "connected"), false) + assert.equal(gate.observe("instance", "error"), false) + assert.equal(gate.observe("instance", "connected"), true) + assert.equal(gate.observe("instance", "connected"), false) + }) + + it("does not resync a deliberately stopped or cleared workspace", () => { + const gate = new ConnectionResyncGate() + assert.equal(gate.observe("instance", "disconnected", "workspace stopped"), false) + assert.equal(gate.observe("instance", "connected"), false) + gate.observe("instance", "disconnected") + gate.clear("instance") + assert.equal(gate.observe("instance", "connected"), false) + }) +}) diff --git a/packages/ui/src/stores/connection-resync-gate.ts b/packages/ui/src/stores/connection-resync-gate.ts new file mode 100644 index 00000000..88d30b96 --- /dev/null +++ b/packages/ui/src/stores/connection-resync-gate.ts @@ -0,0 +1,18 @@ +import type { InstanceStreamStatus } from "../../../server/src/api-types" + +export class ConnectionResyncGate { + private readonly pending = new Set() + + observe(instanceId: string, status: InstanceStreamStatus, reason?: string): boolean { + if (status === "error" || (status === "disconnected" && reason !== "workspace stopped")) { + this.pending.add(instanceId) + return false + } + if (status !== "connected") return false + return this.pending.delete(instanceId) + } + + clear(instanceId: string): void { + this.pending.delete(instanceId) + } +} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 271e5c94..79e73dc4 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -32,6 +32,7 @@ import { getRootClient } from "./opencode-client" import { buildV2RequestLocations } from "./request-locations" import { fetchCommands, clearCommands } from "./commands" import { getInstanceRefreshTargets, type InstanceRefreshTarget } from "./instance-invalidation" +import { ConnectionResyncGate } from "./connection-resync-gate" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, @@ -271,6 +272,7 @@ const connectionResyncs = new TrailingResyncCoordinator( log.warn("Failed to resync sessions after instance connection", { instanceId, error }) }, ) +const connectionResyncGate = new ConnectionResyncGate() function resyncConnectedInstance(instanceId: string): void { void connectionResyncs.request(instanceId) @@ -313,11 +315,13 @@ function refreshVolatileInstanceState( } serverEvents.on("instance.eventStatus", (event) => { - if (event.type !== "instance.eventStatus" || event.status !== "connected") return + if (event.type !== "instance.eventStatus") return + const shouldResync = connectionResyncGate.observe(event.instanceId, event.status, event.reason) + if (event.status !== "connected") return if (disconnectedInstance()?.id === event.instanceId) { setDisconnectedInstance(null) } - resyncConnectedInstance(event.instanceId) + if (shouldResync) resyncConnectedInstance(event.instanceId) }) function createRestoreCreationRequestId(): string { @@ -1064,6 +1068,7 @@ function updateInstance(id: string, updates: Partial) { } function removeInstance(id: string, options: { authoritative?: boolean } = {}) { + connectionResyncGate.clear(id) const removedInstance = instances().get(id) const removedOccurrence = removedInstance ? Array.from(instances().values())