From a9904a2cddb4cd6937295762ab8ed3867edc5be8 Mon Sep 17 00:00:00 2001 From: agustif Date: Fri, 16 Oct 2026 06:22:51 +0200 Subject: [PATCH 1/6] fix(tui): only flag session location missing on LocationNotFoundError (cherry picked from commit 3f47f523336db67ebb258092cecbea7c15f9cb8f) Co-authored-by: Ivan Milev --- packages/tui/src/context/location.tsx | 4 + .../tui/test/cli/tui/location-error.test.tsx | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 packages/tui/test/cli/tui/location-error.test.tsx diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 8c668757e75d..212e68e8633a 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,4 +1,5 @@ import type { LocationGetOutput, LocationRef } from "@opencode/client" +import { isLocationNotFoundError } from "@opencode/client" import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js" import { useClient } from "./client" import { useData } from "./data" @@ -36,6 +37,9 @@ export function LocationProvider(props: ParentProps) { current.workspaceID !== location.workspaceID ) return + // Only the server's typed LocationNotFoundError proves the folder is gone. + // Transient sync failures (reconnect, timeouts) must not mount recovery. + if (!isLocationNotFoundError(cause)) return setError({ location, cause }) }) } diff --git a/packages/tui/test/cli/tui/location-error.test.tsx b/packages/tui/test/cli/tui/location-error.test.tsx new file mode 100644 index 000000000000..b9141af8d959 --- /dev/null +++ b/packages/tui/test/cli/tui/location-error.test.tsx @@ -0,0 +1,93 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { LocationProvider, useLocation } from "../../../src/context/location" +import { Keymap } from "../../../src/context/keymap" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const directory = "/tmp/opencode/missing-clone" + +test("transient location sync failure does not flag the location as missing", async () => { + const fixture = await renderLocation("transient") + try { + fixture.location.set({ directory }) + await fixture.app.waitFor(() => fixture.reads.includes(directory)) + // Let the sync rejection settle through LocationProvider's catch. + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(fixture.location.error).toBeUndefined() + } finally { + fixture.app.renderer.destroy() + } +}) + +test("typed LocationNotFoundError flags the location as missing", async () => { + const fixture = await renderLocation("not-found") + try { + fixture.location.set({ directory }) + await fixture.app.waitFor(() => fixture.location.error !== undefined) + + expect(fixture.location.error?.location.directory).toBe(directory) + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderLocation(mode: "transient" | "not-found") { + const reads: string[] = [] + const calls = createFetch(async (url) => { + if (url.pathname === "/api/location") { + const requested = url.searchParams.get("location[directory]") ?? "" + reads.push(requested) + if (requested === directory) { + if (mode === "not-found") + return json( + { + _tag: "LocationNotFoundError", + location: { directory }, + message: `Location not found: ${directory}`, + }, + { status: 404 }, + ) + return json({ message: "Location unavailable" }, { status: 503 }) + } + } + return undefined + }, createEventStream()) + let data!: ReturnType + let location!: ReturnType + + function Probe() { + data = useData() + location = useLocation() + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitFor(() => location !== undefined) + + return { app, data, location, reads } +} From c8de42488a2ccab0ee4080e421d01c67dfa3efb6 Mon Sep 17 00:00:00 2001 From: agustif Date: Fri, 16 Oct 2026 07:47:38 +0200 Subject: [PATCH 2/6] feat(server): restart approval intent protocol and endpoints (cherry picked from commit 87a27cfc157d0179ba6a865650da414b46ad1b53) Co-authored-by: Ivan Milev --- packages/client/src/effect/api/api.ts | 15 +++++++- .../client/src/effect/generated/client.ts | 12 +++++++ .../client/src/promise/generated/client.ts | 28 +++++++++++++++ .../client/src/promise/generated/types.ts | 32 +++++++++++++++-- packages/protocol/src/groups/server.ts | 36 ++++++++++++++++++- packages/schema/src/event-manifest.ts | 3 ++ packages/schema/src/service-event.ts | 28 +++++++++++++++ packages/server/src/handlers.ts | 3 +- packages/server/src/handlers/server.ts | 29 ++++++++++++++- packages/server/src/restart-intent.ts | 31 ++++++++++++++++ 10 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 packages/schema/src/service-event.ts create mode 100644 packages/server/src/restart-intent.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 5310d67cdd20..1867b5bf8568 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1,9 +1,9 @@ // Generated by @opencode/httpapi-codegen. Do not edit. import type { Effect, Stream } from "effect" +import type { Session } from "@opencode/schema/session" import type { Location } from "@opencode/schema/location" import type { Agent } from "@opencode/schema/agent" import type { Plugin } from "@opencode/schema/plugin" -import type { Session } from "@opencode/schema/session" import type { AbsolutePath } from "@opencode/schema/schema" import type { Project } from "@opencode/schema/project" import type { RelativePath } from "@opencode/schema/schema" @@ -59,10 +59,23 @@ export type ServerConnectInput = { readonly code: string } export type ServerConnectOutput = { readonly token: string } export type ServerConnectOperation = (input: ServerConnectInput) => Effect.Effect +export type ServerRestartRequestOutput = { readonly active: ReadonlyArray } +export type ServerRestartRequestOperation = () => Effect.Effect + +export type ServerRestartResolveInput = { readonly decision: "now" | "idle" | "cancel" } +export type ServerRestartResolveOutput = { readonly decision: "now" | "idle" | "cancel" } +export type ServerRestartResolveOperation = ( + input: ServerRestartResolveInput, +) => Effect.Effect + export interface ServerApi { readonly info: ServerInfoOperation readonly pair: ServerPairOperation readonly connect: ServerConnectOperation + readonly restart: { + readonly request: ServerRestartRequestOperation + readonly resolve: ServerRestartResolveOperation + } } export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index c407bd2fcc85..952d2a018def 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -9,6 +9,9 @@ import type { ServerPairOutput, ServerConnectInput, ServerConnectOutput, + ServerRestartRequestOutput, + ServerRestartResolveInput, + ServerRestartResolveOutput, LocationGetInput, LocationGetOutput, LocationReloadOutput, @@ -310,10 +313,19 @@ const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: Serve raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)), ) +const EndpointServerRestartRequest = (raw: RawClient["server.server"]) => () => + preserveEffect()(raw["server.restart.request"]({}).pipe(Effect.mapError(mapClientError))) + +const EndpointServerRestartResolve = (raw: RawClient["server.server"]) => (input: ServerRestartResolveInput) => + preserveEffect()( + raw["server.restart.resolve"]({ payload: { decision: input["decision"] } }).pipe(Effect.mapError(mapClientError)), + ) + const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw), pair: EndpointServerPair(raw), connect: EndpointServerConnect(raw), + restart: { request: EndpointServerRestartRequest(raw), resolve: EndpointServerRestartResolve(raw) }, }) const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 50e154849745..72ad16ef4ccf 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -3,6 +3,9 @@ import type { ServerPairOutput, ServerConnectInput, ServerConnectOutput, + ServerRestartRequestOutput, + ServerRestartResolveInput, + ServerRestartResolveOutput, LocationGetInput, LocationGetOutput, LocationReloadOutput, @@ -443,6 +446,31 @@ export function make(options: ClientOptions) { }, requestOptions, ), + restart: { + request: (requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/server/restart/request`, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + resolve: (input: ServerRestartResolveInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/server/restart/resolve`, + body: { decision: input["decision"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + }, }, location: { get: (input?: LocationGetInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1d27265ccdf3..da6980b61eb5 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -12,6 +12,8 @@ export type PairingCode = { code: string; expires_in: number } export type PairingSession = { token: string } +export type SessionID = string + export type AbsolutePath = string export type ProjectID = string @@ -40,8 +42,6 @@ export type PluginFeatures = { server?: true; tui?: true; rpc?: true } export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string } -export type SessionID = string - export type SessionMessageID = string export type MoneyUSD = number @@ -1741,6 +1741,24 @@ export type SessionIdle = { data: { sessionID: SessionID } } +export type ServerRestartRequested = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "server.restart.requested" + location?: LocationRef + data: { active: Array } +} + +export type ServerRestartResolved = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "server.restart.resolved" + location?: LocationRef + data: { decision: "now" | "idle" | "cancel" } +} + export type TuiPromptAppend = { id: EventID created: number @@ -2627,6 +2645,8 @@ export type V2Event = | WebsearchUpdated | SessionStatusUpdated | SessionIdle + | ServerRestartRequested + | ServerRestartResolved | TuiPromptAppend | TuiCommandExecute | TuiToastShow @@ -2895,6 +2915,14 @@ export type ServerConnectInput = { readonly code: { readonly code: string }["cod export type ServerConnectOutput = PairingSession +export type ServerRestartRequestOutput = { active: Array } + +export type ServerRestartResolveInput = { + readonly decision: { readonly decision: "now" | "idle" | "cancel" }["decision"] +} + +export type ServerRestartResolveOutput = { decision: "now" | "idle" | "cancel" } + export type LocationGetInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] } diff --git a/packages/protocol/src/groups/server.ts b/packages/protocol/src/groups/server.ts index 4b4cab189bf2..a9220f5c5398 100644 --- a/packages/protocol/src/groups/server.ts +++ b/packages/protocol/src/groups/server.ts @@ -1,6 +1,8 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" -import { UnauthorizedError } from "../errors.js" +import { ServiceEvent } from "@opencode/schema/service-event" +import { Session } from "@opencode/schema/session" +import { InvalidRequestError, UnauthorizedError } from "../errors.js" export const ServerInfo = Schema.Struct({ version: Schema.String, @@ -75,4 +77,36 @@ export const ServerGroup = HttpApiGroup.make("server.server") }), ), ) + .add( + HttpApiEndpoint.post("server.restart.request", "/api/server/restart/request", { + success: Schema.Struct({ + active: Schema.Array(Session.ID), + }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "server.restart.request", + summary: "Request service restart approval", + description: + "Register a pending service restart. Returns the sessions with running agents so the requester can seek approval. Resolving the request is reported through server.restart events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("server.restart.resolve", "/api/server/restart/resolve", { + payload: Schema.Struct({ + decision: ServiceEvent.Decision, + }), + success: Schema.Struct({ + decision: ServiceEvent.Decision, + }), + error: InvalidRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "server.restart.resolve", + summary: "Resolve pending service restart", + description: + "Approve the pending service restart (now or once sessions go idle) or cancel it. Only the first resolution wins.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "server" })) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 341cc1675d53..afb753f8342d 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -27,6 +27,7 @@ import { Worktree } from "./worktree.js" import { Pty } from "./pty.js" import { Reference } from "./reference.js" import { ServerEvent } from "./server-event.js" +import { ServiceEvent } from "./service-event.js" import { Shell } from "./shell.js" import { Skill } from "./skill.js" import { SessionCompactionEvent } from "./session-compaction-event.js" @@ -73,6 +74,7 @@ export const ServerDefinitions = Event.inventory( ...featureDefinitions, // Current events the TUI consumes from the public stream. ...SessionStatusEvent.Definitions, + ...ServiceEvent.Definitions, ...TuiEvent.Definitions, ...InstallationEvent.Definitions, ...VcsEvent.Definitions, @@ -94,6 +96,7 @@ export const Definitions = Event.inventory( ...FileSystemV1.Event.Definitions, ...SessionStatusEvent.Definitions, ...SessionCompactionEvent.Definitions, + ...ServiceEvent.Definitions, ...VcsEvent.Definitions, ...WorkspaceEvent.Definitions, ...WorktreeEvent.Definitions, diff --git a/packages/schema/src/service-event.ts b/packages/schema/src/service-event.ts new file mode 100644 index 000000000000..00c378378a4e --- /dev/null +++ b/packages/schema/src/service-event.ts @@ -0,0 +1,28 @@ +export * as ServiceEvent from "./service-event.js" + +import { Schema } from "effect" +import { Event } from "./event.js" +import { SessionID } from "./session-id.js" + +export const Requested = Event.ephemeral({ + type: "server.restart.requested", + schema: { + active: Schema.Array(SessionID), + }, +}) + +export const Decision = Schema.Union([ + Schema.Literal("now"), + Schema.Literal("idle"), + Schema.Literal("cancel"), +]) +export type Decision = typeof Decision.Type + +export const Resolved = Event.ephemeral({ + type: "server.restart.resolved", + schema: { + decision: Decision, + }, +}) + +export const Definitions = Event.inventory(Requested, Resolved) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 5dccd1d847ae..5bb277c5d2cd 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -14,6 +14,7 @@ import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { PluginHandler } from "./handlers/plugin" import { ServerHandler } from "./handlers/server" +import { RestartIntent } from "./restart-intent" import { DebugHandler } from "./handlers/debug" import { PtyHandler } from "./handlers/pty" import { PersistentPtyHandler } from "./handlers/persistent-pty" @@ -32,7 +33,7 @@ import { MigrationHandler } from "./handlers/migration" import { ConfigHandler } from "./handlers/config" export const handlers = Layer.mergeAll( - ServerHandler, + ServerHandler.pipe(Layer.provide(RestartIntent.layer)), DebugHandler, MigrationHandler, LocationHandler, diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts index d85594b38d42..8818e0084daf 100644 --- a/packages/server/src/handlers/server.ts +++ b/packages/server/src/handlers/server.ts @@ -2,16 +2,23 @@ import { Duration, Effect } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/http" import { HttpApiBuilder } from "effect/http-api" import { PersistentPty } from "@opencode/core/persistent-pty" -import { UnauthorizedError } from "@opencode/protocol/errors" +import { Session } from "@opencode/core/session" +import { Bus } from "@opencode/core/bus" +import { ServiceEvent } from "@opencode/schema/service-event" +import { InvalidRequestError, UnauthorizedError } from "@opencode/protocol/errors" import { Api } from "../api" import { ServerAuth } from "../auth" import { ServerInfo } from "../server-info" import { ServerPairing } from "../pairing" +import { RestartIntent } from "../restart-intent" export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) => Effect.gen(function* () { const pairing = yield* ServerPairing.Service const auth = yield* ServerAuth.Config + const session = yield* Session.Service + const intent = yield* RestartIntent.Service + const bus = yield* Bus.Service return handlers .handle("server.info", () => @@ -27,6 +34,26 @@ export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handler }), ) .handle("server.pair", () => pairing.issue()) + .handle( + "server.restart.request", + Effect.fn(function* () { + const active = Array.from(yield* session.active) + yield* intent.request() + yield* bus.publish(ServiceEvent.Requested, { active }, { global: true }) + return { active } + }), + ) + .handle( + "server.restart.resolve", + Effect.fn(function* (ctx) { + const winner = yield* intent.resolve(ctx.payload.decision) + if (winner === undefined) { + return yield* new InvalidRequestError({ message: "No pending service restart request" }) + } + yield* bus.publish(ServiceEvent.Resolved, { decision: winner }, { global: true }) + return { decision: winner } + }), + ) .handle( "server.connect", Effect.fn(function* (ctx) { diff --git a/packages/server/src/restart-intent.ts b/packages/server/src/restart-intent.ts new file mode 100644 index 000000000000..c085155c1127 --- /dev/null +++ b/packages/server/src/restart-intent.ts @@ -0,0 +1,31 @@ +export * as RestartIntent from "./restart-intent" + +import { ServiceEvent } from "@opencode/schema/service-event" +import { Context, Effect, Layer, Ref } from "effect" + +export class Service extends Context.Service Effect.Effect + /** + * Resolve the pending intent with first-wins semantics. Returns the winning + * decision, or undefined when no intent is pending. + */ + readonly resolve: (decision: ServiceEvent.Decision) => Effect.Effect +}>()("@opencode/server/RestartIntent") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* Ref.make<{ readonly pending: boolean; readonly last?: ServiceEvent.Decision }>({ + pending: false, + }) + return Service.of({ + request: () => Ref.update(state, (current) => (current.pending ? current : { pending: true })), + resolve: (decision) => + Ref.modify(state, (current) => { + if (!current.pending) return [current.last, current] as const + return [decision, { pending: false, last: decision }] as const + }), + }) + }), +) From 17260d7e9d1e057ae6d70dd0beeb19d7a2db7677 Mon Sep 17 00:00:00 2001 From: agustif Date: Fri, 16 Oct 2026 07:47:38 +0200 Subject: [PATCH 3/6] feat(cli): guard service restart with approval (cherry picked from commit b68002d7e83be8f9334e1141e1a92d44f230769f) Co-authored-by: Ivan Milev --- packages/cli/src/commands/commands.ts | 10 +- .../src/commands/handlers/service/restart.ts | 153 ++++++++++- .../cli/test/service-restart-approval.test.ts | 242 ++++++++++++++++++ 3 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/service-restart-approval.test.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index ba3454b6b97f..26c5d46cd91d 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -472,7 +472,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME description: "Manage the background server", commands: [ Spec.make("start", { description: "Start the background server" }), - Spec.make("restart", { description: "Restart the background server" }), + Spec.make("restart", { + description: "Restart the background server", + params: { + force: Flag.Boolean("force").pipe( + Flag.withDescription("Restart immediately without asking running sessions for approval"), + Flag.withDefault(false), + ), + }, + }), Spec.make("status", { description: "Show background server status" }), Spec.make("stop", { description: "Stop the background server" }), Spec.make("get", { diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index afc7bb457c17..771a187a99ea 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -1,15 +1,31 @@ +import { select } from "@clack/prompts" import { EOL } from "os" import { Effect } from "effect" import { Service } from "@opencode/client/effect/service" +import { OpenCode, type OpenCodeClient } from "@opencode/client/promise" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" import { ServerConnection } from "../../../services/server-connection" +import { prompt } from "../../../ui/prompt" export default Runtime.handler( Commands.commands.service.commands.restart, - Effect.fn("cli.service.restart")(function* () { + Effect.fn("cli.service.restart")(function* (input: Runtime.Input) { const options = yield* ServiceConfig.options() + const endpoint = yield* Service.discover(options) + if (endpoint !== undefined && !input.force) { + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + yield* resolveRestartApproval(client, { + force: input.force, + interactive: process.stdin.isTTY === true && process.stdout.isTTY === true, + ask: askRestartDecision, + resolveTimeoutMs: 120_000, + idlePollMs: 2_000, + onWaiting: (active) => + process.stdout.write(`Waiting for ${active.length} active session(s) to finish...${EOL}`), + }) + } // Keep this explicit: automatic service replacement must preserve terminals. yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore) yield* Service.stop(options) @@ -17,3 +33,138 @@ export default Runtime.handler( process.stdout.write(transport.url + EOL) }), ) + +export type RestartApprovalDecision = "now" | "idle" | "cancel" + +export type RestartApprovalDeps = { + readonly force: boolean + readonly interactive: boolean + readonly ask: (active: ReadonlyArray) => Effect.Effect + readonly resolveTimeoutMs: number + readonly idlePollMs: number + readonly onWaiting: (active: ReadonlyArray) => void +} + +// Approval gate for restarting a managed server with running sessions. Resolves to the winning +// decision ("now" when nothing needs approval); "cancel" and approval timeouts fail instead of +// restarting. The server counts the first resolve as the winner, so a local pick is adopted +// from the resolve response rather than trusted blindly. +export const resolveRestartApproval = Effect.fn("cli.service.restart.approval")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, +) { + const active = yield* Effect.tryPromise({ + try: () => client.session.active(), + catch: (cause) => new Error("Could not reach server to check active sessions", { cause }), + }) + const ids = Object.keys(active) + if (ids.length === 0) return "now" + if (deps.force) return "now" + yield* Effect.tryPromise({ + try: () => client.server.restart.request(), + catch: (cause) => new Error("Could not reach server to request restart approval", { cause }), + }) + const winner = deps.interactive ? yield* resolveInteractive(client, deps, ids) : yield* resolveRemote(client, deps) + if (winner === "cancel") return yield* Effect.fail(restartAborted(ids)) + if (winner === "idle") yield* waitForIdle(client, deps, ids) + return winner +}) + +const resolveInteractive = Effect.fn("cli.service.restart.approval.interactive")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, + active: ReadonlyArray, +) { + const picked = yield* deps.ask(active) + const resolved = yield* Effect.tryPromise({ + try: () => client.server.restart.resolve({ decision: picked }), + catch: (cause) => new Error("Could not reach server to resolve restart approval", { cause }), + }) + return resolved.decision +}) + +const resolveRemote = Effect.fn("cli.service.restart.approval.remote")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, +) { + return yield* Effect.tryPromise({ + try: () => waitForResolvedEvent(client, deps.resolveTimeoutMs), + catch: (cause) => + cause instanceof RestartApprovalTimeout + ? new Error( + "Timed out waiting for restart approval; no connected client approved the restart. Re-run with --force to restart immediately.", + ) + : new Error("Lost connection to the server while waiting for restart approval", { cause }), + }) +}) + +const waitForIdle = Effect.fn("cli.service.restart.approval.idle")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, + initial: ReadonlyArray, +) { + deps.onWaiting(initial) + while (true) { + yield* Effect.sleep(deps.idlePollMs) + const active = yield* Effect.tryPromise({ + try: () => client.session.active(), + catch: (cause) => + new Error("Lost connection to the server while waiting for sessions to finish", { cause }), + }) + const ids = Object.keys(active) + if (ids.length === 0) return + deps.onWaiting(ids) + } +}) + +function restartAborted(active: ReadonlyArray) { + return new Error( + `Restart cancelled with ${active.length} active session(s); re-run with --force to restart immediately.`, + ) +} + +class RestartApprovalTimeout extends Error { + constructor() { + super("restart approval timed out") + this.name = "RestartApprovalTimeout" + } +} + +function waitForResolvedEvent(client: OpenCodeClient, timeoutMs: number): Promise { + return (async () => { + const controller = new AbortController() + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + controller.abort() + }, timeoutMs) + try { + const stream = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]() + while (true) { + const next = await stream.next() + if (next.done) break + if (next.value.type === "server.restart.resolved") return next.value.data.decision + } + if (timedOut) throw new RestartApprovalTimeout() + throw new Error("Event stream disconnected while waiting for restart approval") + } catch (cause) { + if (timedOut) throw new RestartApprovalTimeout() + throw cause + } finally { + clearTimeout(timer) + controller.abort() + } + })() +} + +const askRestartDecision = (active: ReadonlyArray) => + prompt(() => + select({ + message: `${active.length} session(s) are still running. Restart the background server?`, + options: [ + { value: "now", label: "Restart now", hint: "Interrupt running sessions" }, + { value: "idle", label: "Wait for idle", hint: "Restart when sessions finish" }, + { value: "cancel", label: "Cancel", hint: "Keep the server running" }, + ], + }), + ) diff --git a/packages/cli/test/service-restart-approval.test.ts b/packages/cli/test/service-restart-approval.test.ts new file mode 100644 index 000000000000..e9ad9f622f77 --- /dev/null +++ b/packages/cli/test/service-restart-approval.test.ts @@ -0,0 +1,242 @@ +import { OpenCode, type OpenCodeClient } from "@opencode/client/promise" +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { + resolveRestartApproval, + type RestartApprovalDecision, + type RestartApprovalDeps, +} from "../src/commands/handlers/service/restart" + +type StubState = { + active: Record + activeCalls: number + drainAfterActiveCalls?: number + activeFails: boolean + requests: number + resolves: Array + winner: RestartApprovalDecision | undefined + sseConnections: number +} + +function startStub(initial: Partial = {}) { + const state: StubState = { + active: {}, + activeCalls: 0, + activeFails: false, + requests: 0, + resolves: [], + winner: undefined, + sseConnections: 0, + ...initial, + } + const controllers = new Set>() + const broadcast = (event: unknown) => { + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + for (const controller of controllers) { + try { + controller.enqueue(chunk) + } catch { + controllers.delete(controller) + } + } + } + const resolve = (decision: RestartApprovalDecision) => { + if (state.winner === undefined) { + state.winner = decision + broadcast({ type: "server.restart.resolved", data: { decision: state.winner } }) + } + return { decision: state.winner } + } + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url) + if (request.method === "GET" && url.pathname === "/api/session/active") { + state.activeCalls += 1 + if (state.activeFails) return new Response("gone", { status: 500 }) + if (state.drainAfterActiveCalls !== undefined && state.activeCalls > state.drainAfterActiveCalls) + state.active = {} + return Response.json({ data: state.active }) + } + if (request.method === "POST" && url.pathname === "/api/server/restart/request") { + state.requests += 1 + return Response.json({ active: Object.keys(state.active) }) + } + if (request.method === "POST" && url.pathname === "/api/server/restart/resolve") { + const body = (await request.json()) as { decision: RestartApprovalDecision } + state.resolves.push(body.decision) + return Response.json(resolve(body.decision)) + } + if (request.method === "GET" && url.pathname === "/api/event") { + const stream = new ReadableStream({ + start: (controller) => { + state.sseConnections += 1 + controllers.add(controller) + }, + cancel: (controller) => { + controllers.delete(controller) + }, + }) + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }) + } + return new Response("not found", { status: 404 }) + }, + }) + const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${server.port}`, headers: {} }) + return { state, client, url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) } +} + +const deps = (overrides: Partial = {}): RestartApprovalDeps => ({ + force: false, + interactive: false, + ask: () => Effect.succeed("now"), + resolveTimeoutMs: 5_000, + idlePollMs: 10, + onWaiting: () => {}, + ...overrides, +}) + +const run = (client: OpenCodeClient, input: RestartApprovalDeps) => + Effect.runPromise(resolveRestartApproval(client, input)) + +async function until(condition: () => boolean, message: string) { + const deadline = Date.now() + 5_000 + while (!condition()) { + if (Date.now() >= deadline) throw new Error(message) + await Bun.sleep(10) + } +} + +async function tuiResolve(url: string, decision: RestartApprovalDecision) { + const response = await fetch(`${url}/api/server/restart/resolve`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision }), + }) + if (!response.ok) throw new Error(`TUI resolve failed: ${response.status}`) +} + +test("no active sessions restarts without requesting approval", async () => { + const stub = startStub() + try { + expect(await run(stub.client, deps())).toBe("now") + expect(stub.state.requests).toBe(0) + } finally { + await stub.stop() + } +}) + +test("active sessions with --force restart without requesting approval", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + expect(await run(stub.client, deps({ force: true }))).toBe("now") + expect(stub.state.requests).toBe(0) + expect(stub.state.resolves).toEqual([]) + } finally { + await stub.stop() + } +}) + +test("non-TTY waits for a TUI decision and obeys the winner", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await tuiResolve(stub.url, "now") + expect(await pending).toBe("now") + expect(stub.state.requests).toBe(1) + // The CLI never resolves itself off-terminal; the only resolve is the TUI's. + expect(stub.state.resolves).toEqual(["now"]) + } finally { + await stub.stop() + } +}) + +test("non-TTY timeout aborts and names --force", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const error = await run(stub.client, deps({ resolveTimeoutMs: 200 })).then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("--force") + expect(stub.state.requests).toBe(1) + expect(stub.state.resolves).toEqual([]) + } finally { + await stub.stop() + } +}) + +test("a TUI cancel aborts the restart", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await tuiResolve(stub.url, "cancel") + const error = await pending.then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("--force") + } finally { + await stub.stop() + } +}) + +test("an idle winner waits until sessions finish", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } }, drainAfterActiveCalls: 3 }) + try { + const waited: Array> = [] + const pending = run( + stub.client, + deps({ onWaiting: (active) => waited.push(active) }), + ) + await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await tuiResolve(stub.url, "idle") + expect(await pending).toBe("idle") + expect(waited.length).toBeGreaterThan(0) + } finally { + await stub.stop() + } +}) + +test("an idle wait aborts when the server connection drops", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await tuiResolve(stub.url, "idle") + await until(() => stub.state.activeCalls > 1, "CLI did not poll for idle") + stub.state.activeFails = true + const error = await pending.then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("Lost connection") + } finally { + await stub.stop() + } +}) + +test("a TTY pick adopts the first-wins winner", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + // A TUI decides first; the later local pick loses and the winner stands. + await tuiResolve(stub.url, "cancel") + const error = await run(stub.client, deps({ interactive: true })).then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("--force") + expect(stub.state.resolves).toEqual(["cancel", "now"]) + } finally { + await stub.stop() + } +}) From 8d33e45dcd6f7535d319194ec994cf100b5f95ac Mon Sep 17 00:00:00 2001 From: agustif Date: Fri, 16 Oct 2026 07:47:38 +0200 Subject: [PATCH 4/6] feat(tui): restart approval dialog (cherry picked from commit 915263207fb30606297a429bf0e29a84fce9c275) Co-authored-by: Ivan Milev --- packages/tui/src/app.tsx | 71 ++++++++- packages/tui/src/context/restart-approval.tsx | 96 ++++++++++++ .../test/cli/tui/restart-approval.test.tsx | 147 ++++++++++++++++++ 3 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/context/restart-approval.tsx create mode 100644 packages/tui/test/cli/tui/restart-approval.test.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 88939ebc8528..ba92d2c39b74 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -94,6 +94,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" import { newSessionLocation } from "./config/new-session-location" import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification" +import { RestartApprovalProvider } from "./context/restart-approval" import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context" import { localPluginDirectories } from "./plugin/discovery" import { PluginRoute, Slot } from "./plugin/render" @@ -398,7 +399,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + @@ -1046,12 +1049,66 @@ function App() { const restart = client.restart if (!restart) return dialog.clear() - toast.show({ variant: "info", message: "Restarting service…", duration: 30000 }) - // restart resolves once the replacement service is healthy; the - // event stream reattaches through the reconnect loop. - await restart() - .then(() => toast.show({ variant: "success", message: "Service restarted" })) - .catch(toast.error) + const restartNow = () => { + toast.show({ variant: "info", message: "Restarting service…", duration: 30000 }) + // restart resolves once the replacement service is healthy; the + // event stream reattaches through the reconnect loop. + return restart() + .then(() => toast.show({ variant: "success", message: "Service restarted" })) + .catch(toast.error) + } + const waitForIdle = async () => { + for (;;) { + const active = await client.api.session.active().catch(() => undefined) + if (!active || Object.keys(active).length === 0) return + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } + const waitForDecision = () => + new Promise<"now" | "idle" | "cancel">((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Restart request expired with no approver")), 120000) + const dispose = event.on("server.restart.resolved", (evt) => { + clearTimeout(timer) + dispose() + resolve(evt.data.decision) + }) + }) + const active = await client.api.session.active().catch(() => undefined) + if (!active || Object.keys(active).length === 0) { + await restartNow() + return + } + let requested + try { + requested = await client.api.server.restart.request() + } catch { + // Servers older than restart intents cannot approve; fall back. + toast.show({ + variant: "warning", + message: "Server does not support restart approval; restarting directly…", + }) + await restartNow() + return + } + if (Object.keys(requested.active).length === 0) { + await restartNow() + return + } + toast.show({ + variant: "info", + message: `Waiting for restart approval (${Object.keys(requested.active).length} running)…`, + duration: 120000, + }) + const winner = await waitForDecision().catch(async () => { + await client.api.server.restart.resolve({ decision: "cancel" }).catch(() => undefined) + return "cancel" as const + }) + if (winner === "now") await restartNow() + else if (winner === "idle") { + toast.show({ variant: "info", message: "Restart queued; waiting for sessions to finish…" }) + await waitForIdle() + await restartNow() + } else toast.show({ variant: "info", message: "Restart cancelled" }) }, category: "System", }, diff --git a/packages/tui/src/context/restart-approval.tsx b/packages/tui/src/context/restart-approval.tsx new file mode 100644 index 000000000000..4c139eac6d09 --- /dev/null +++ b/packages/tui/src/context/restart-approval.tsx @@ -0,0 +1,96 @@ +import type { ServiceEvent } from "@opencode/schema/service-event" +import { createSignal, For, Show } from "solid-js" +import { SessionQuestion } from "../routes/session/permission" +import { useDialog } from "../ui/dialog" +import { useToast } from "../ui/toast" +import { errorMessage } from "../util/error" +import { useClient } from "./client" +import { useData } from "./data" +import { useEvent } from "./event" +import { createSimpleContext } from "./helper" + +export const { use: useRestartApproval, provider: RestartApprovalProvider } = + createSimpleContext({ + name: "RestartApproval", + init: () => { + const client = useClient() + const dialog = useDialog() + const event = useEvent() + const toast = useToast() + const [active, setActive] = createSignal>([]) + + function open(sessions: ReadonlyArray) { + setActive(sessions) + dialog.replace(() => ( + void decide(decision)} + /> + )) + } + + function close() { + setActive([]) + dialog.clear() + } + + async function decide(decision: ServiceEvent.Decision) { + try { + await client.api.server.restart.resolve({ decision }) + } catch (error) { + toast.show({ + title: "Failed to resolve restart", + message: errorMessage(error), + variant: "error", + }) + } + close() + } + + event.on("server.restart.requested", (evt) => open(evt.data.active)) + event.on("server.restart.resolved", () => close()) + + return { open, close, decide } + }, + }) + +function RestartApprovalDialog(props: { + active: ReadonlyArray + onSelect: (decision: ServiceEvent.Decision) => void +}) { + const data = useData() + + return ( + + + The following sessions have running agents and would be interrupted: + + + + {(sessionID) => ( + - {data.session.get(sessionID)?.title ?? sessionID} + )} + + + + No sessions are running anymore. + + + } + options={{ + now: "Restart now", + idle: "When idle", + cancel: "Cancel restart", + }} + escapeKey="cancel" + onSelect={(option) => props.onSelect(option)} + /> + ) +} diff --git a/packages/tui/test/cli/tui/restart-approval.test.tsx b/packages/tui/test/cli/tui/restart-approval.test.tsx new file mode 100644 index 000000000000..49f3fc3c1ad4 --- /dev/null +++ b/packages/tui/test/cli/tui/restart-approval.test.tsx @@ -0,0 +1,147 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { + RestartApprovalProvider, + useRestartApproval, +} from "../../../src/context/restart-approval" +import { ThemeProvider } from "../../../src/context/theme" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { emptyThemeSource } from "../../fixture/fixture" +import { + createApi, + createEventStream, + createFetch, + json, +} from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const directory = "/tmp/opencode/restart-approval" + +test("restart request opens approval dialog and deciding resolves the intent", async () => { + const fixture = await renderApproval() + try { + await fixture.data.session.sync("ses_1") + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1", "ses_2"] }, + }) + await fixture.app.waitForFrame((frame) => + frame.includes("Restart service with running agents?"), + ) + const frame = await fixture.app.waitForFrame((frame) => + frame.includes("Lane one"), + ) + expect(frame).toContain("ses_2") + + await fixture.approval.decide("idle") + + await fixture.app.waitFor(() => fixture.resolves.length === 1) + expect(fixture.resolves).toEqual([{ decision: "idle" }]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("resolution from another surface dismisses the dialog", async () => { + const fixture = await renderApproval() + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => + frame.includes("Restart service with running agents?"), + ) + fixture.events.emit({ + id: "evt_restart_resolved", + type: "server.restart.resolved", + created: 2, + data: { decision: "now" }, + }) + await fixture.app.waitForFrame( + (frame) => !frame.includes("Restart service with running agents?"), + ) + expect(fixture.resolves).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderApproval() { + const resolves: unknown[] = [] + const events = createEventStream() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/server/restart/resolve") { + const payload = await request.json() + resolves.push(payload) + return json({ decision: payload.decision }) + } + if (url.pathname === "/api/session/ses_1") { + return json({ + data: { + id: "ses_1", + projectID: "proj_test", + location: { directory }, + title: "Lane one", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + time: { created: 1, updated: 1 }, + }, + }) + } + return undefined + }, events) + let data!: ReturnType + let approval!: ReturnType + + function Probe() { + data = useData() + approval = useRestartApproval() + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitFor(() => approval !== undefined) + + return { app, data, approval, events, resolves } +} From 63a2f6faefed9dd8f2148ce2515b4a228d16b426 Mon Sep 17 00:00:00 2001 From: agustif Date: Fri, 16 Oct 2026 08:12:38 +0200 Subject: [PATCH 5/6] fix: address restart approval review feedback - Release pending intents: CLI posts cancel on timeout and disconnect so connected TUIs never stay stuck on a dead request. - Resolve only broadcasts on a fresh settlement; late resolvers replay the winner without re-emitting, and nothing pending returns 400. - Subscribe to the resolved event before requesting an intent in both the CLI and the TUI, so a fast decision cannot be missed. - Exclude the requesting session from the idle wait; it blocks on this command and could never drain. - Keep the --force hint out of the non-interactive path so a blocked agent is not handed its own bypass. (cherry picked from commit 80b3628d3904d764f036c523934af0f016f9f994) Co-authored-by: Ivan Milev --- .../src/commands/handlers/service/restart.ts | 150 +++++++---- .../cli/test/service-restart-approval.test.ts | 98 +++++-- packages/server/src/handlers/server.ts | 10 +- packages/server/src/restart-intent.ts | 22 +- packages/server/test/restart-intent.test.ts | 42 +++ packages/tui/src/app.tsx | 49 +++- packages/tui/src/context/restart-approval.tsx | 144 +++++------ .../test/cli/tui/restart-approval.test.tsx | 240 +++++++++--------- 8 files changed, 467 insertions(+), 288 deletions(-) create mode 100644 packages/server/test/restart-intent.test.ts diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index 771a187a99ea..d6e7a141d7bb 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -1,8 +1,8 @@ import { select } from "@clack/prompts" -import { EOL } from "os" +import { EOL } from "node:os" import { Effect } from "effect" import { Service } from "@opencode/client/effect/service" -import { OpenCode, type OpenCodeClient } from "@opencode/client/promise" +import { OpenCode, isInvalidRequestError, type OpenCodeClient } from "@opencode/client/promise" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" @@ -39,16 +39,18 @@ export type RestartApprovalDecision = "now" | "idle" | "cancel" export type RestartApprovalDeps = { readonly force: boolean readonly interactive: boolean - readonly ask: (active: ReadonlyArray) => Effect.Effect + readonly ask: (active: ReadonlyArray) => Effect.Effect readonly resolveTimeoutMs: number readonly idlePollMs: number readonly onWaiting: (active: ReadonlyArray) => void } -// Approval gate for restarting a managed server with running sessions. Resolves to the winning -// decision ("now" when nothing needs approval); "cancel" and approval timeouts fail instead of -// restarting. The server counts the first resolve as the winner, so a local pick is adopted -// from the resolve response rather than trusted blindly. +// Approval gate for restarting a managed server with running sessions. The event +// subscription starts before the intent request so a resolution cannot slip +// through between them; only decisions recorded after the request count. +// Resolves to the winning decision ("now" when nothing needs approval); "cancel" +// and approval timeouts fail instead of restarting. A local pick is adopted from +// the resolve response, falling back to the broadcast winner on a lost race. export const resolveRestartApproval = Effect.fn("cli.service.restart.approval")(function* ( client: OpenCodeClient, deps: RestartApprovalDeps, @@ -60,50 +62,75 @@ export const resolveRestartApproval = Effect.fn("cli.service.restart.approval")( const ids = Object.keys(active) if (ids.length === 0) return "now" if (deps.force) return "now" - yield* Effect.tryPromise({ - try: () => client.server.restart.request(), - catch: (cause) => new Error("Could not reach server to request restart approval", { cause }), + const watcher = watchResolved(client) + const approval = Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => client.server.restart.request(), + catch: (cause) => new Error("Could not reach server to request restart approval", { cause }), + }) + watcher.arm() + const winner = deps.interactive + ? yield* resolveInteractive(client, deps, watcher, ids) + : yield* resolveRemote(client, deps, watcher) + if (winner === "cancel") return yield* Effect.fail(restartAborted(ids, deps.interactive)) + if (winner === "idle") yield* waitForIdle(client, deps) + return winner }) - const winner = deps.interactive ? yield* resolveInteractive(client, deps, ids) : yield* resolveRemote(client, deps) - if (winner === "cancel") return yield* Effect.fail(restartAborted(ids)) - if (winner === "idle") yield* waitForIdle(client, deps, ids) - return winner + return yield* approval.pipe(Effect.ensuring(Effect.sync(() => watcher.dispose()))) }) const resolveInteractive = Effect.fn("cli.service.restart.approval.interactive")(function* ( client: OpenCodeClient, deps: RestartApprovalDeps, + watcher: ResolvedWatcher, active: ReadonlyArray, ) { const picked = yield* deps.ask(active) - const resolved = yield* Effect.tryPromise({ + return yield* Effect.tryPromise({ + // Pass the rejection through unchanged so catchIf can read its `_tag`. try: () => client.server.restart.resolve({ decision: picked }), - catch: (cause) => new Error("Could not reach server to resolve restart approval", { cause }), - }) - return resolved.decision + catch: (cause) => cause, + }).pipe( + Effect.map((resolved) => resolved.decision), + // Another surface won the race; adopt the broadcast winner instead. + Effect.catchIf(isInvalidRequestError, () => awaitWinner(client, watcher, deps)), + Effect.mapError((cause) => new Error("Could not reach server to resolve restart approval", { cause })), + ) }) const resolveRemote = Effect.fn("cli.service.restart.approval.remote")(function* ( client: OpenCodeClient, deps: RestartApprovalDeps, + watcher: ResolvedWatcher, +) { + return yield* awaitWinner(client, watcher, deps) +}) + +const awaitWinner = Effect.fn("cli.service.restart.approval.wait")(function* ( + client: OpenCodeClient, + watcher: ResolvedWatcher, + deps: RestartApprovalDeps, ) { return yield* Effect.tryPromise({ - try: () => waitForResolvedEvent(client, deps.resolveTimeoutMs), + try: () => watcher.wait(deps.resolveTimeoutMs), catch: (cause) => cause instanceof RestartApprovalTimeout - ? new Error( - "Timed out waiting for restart approval; no connected client approved the restart. Re-run with --force to restart immediately.", - ) + ? new Error("Timed out waiting for restart approval with sessions still running.") : new Error("Lost connection to the server while waiting for restart approval", { cause }), - }) + }).pipe( + Effect.tapError(() => + // Release TUIs stuck on our intent; a settled intent replays its winner. + Effect.tryPromise(() => client.server.restart.resolve({ decision: "cancel" })).pipe(Effect.ignore), + ), + ) }) +// The requesting session blocks on this command, so it can never go idle while +// watched. Exclude it from the drain set; approval itself still required it. const waitForIdle = Effect.fn("cli.service.restart.approval.idle")(function* ( client: OpenCodeClient, deps: RestartApprovalDeps, - initial: ReadonlyArray, ) { - deps.onWaiting(initial) while (true) { yield* Effect.sleep(deps.idlePollMs) const active = yield* Effect.tryPromise({ @@ -111,16 +138,18 @@ const waitForIdle = Effect.fn("cli.service.restart.approval.idle")(function* ( catch: (cause) => new Error("Lost connection to the server while waiting for sessions to finish", { cause }), }) - const ids = Object.keys(active) + const ids = Object.keys(active).filter((id) => id !== process.env.OPENCODE_SESSION_ID) if (ids.length === 0) return deps.onWaiting(ids) } }) -function restartAborted(active: ReadonlyArray) { - return new Error( - `Restart cancelled with ${active.length} active session(s); re-run with --force to restart immediately.`, - ) +function restartAborted(active: ReadonlyArray, interactive: boolean) { + return interactive + ? new Error( + `Restart cancelled with ${active.length} active session(s); re-run with --force to restart immediately.`, + ) + : new Error("Restart cancelled with active session(s) still running.") } class RestartApprovalTimeout extends Error { @@ -130,31 +159,58 @@ class RestartApprovalTimeout extends Error { } } -function waitForResolvedEvent(client: OpenCodeClient, timeoutMs: number): Promise { - return (async () => { - const controller = new AbortController() - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - controller.abort() - }, timeoutMs) +export type ResolvedWatcher = { + readonly arm: () => void + readonly wait: (timeoutMs: number) => Promise + readonly dispose: () => void +} + +export function watchResolved(client: OpenCodeClient): ResolvedWatcher { + const controller = new AbortController() + let armed = false + let pending: RestartApprovalDecision | undefined + let settle: ((decision: RestartApprovalDecision) => void) | undefined + let failure: unknown | undefined + const fail = (cause: unknown) => { + failure ??= cause + settle = undefined + } + void (async () => { try { const stream = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]() - while (true) { + for (;;) { const next = await stream.next() if (next.done) break - if (next.value.type === "server.restart.resolved") return next.value.data.decision + if (armed && next.value.type === "server.restart.resolved") { + const decision = next.value.data.decision + pending ??= decision + settle?.(decision) + } } - if (timedOut) throw new RestartApprovalTimeout() - throw new Error("Event stream disconnected while waiting for restart approval") + fail(new Error("Event stream disconnected while waiting for restart approval")) } catch (cause) { - if (timedOut) throw new RestartApprovalTimeout() - throw cause - } finally { - clearTimeout(timer) - controller.abort() + fail(cause) } })() + return { + arm() { + armed = true + }, + wait(timeoutMs: number) { + return new Promise((resolve, reject) => { + if (pending !== undefined) return resolve(pending) + if (failure !== undefined) return reject(failure) + const timer = setTimeout(() => reject(new RestartApprovalTimeout()), timeoutMs) + settle = (decision) => { + clearTimeout(timer) + resolve(decision) + } + }) + }, + dispose() { + controller.abort() + }, + } } const askRestartDecision = (active: ReadonlyArray) => diff --git a/packages/cli/test/service-restart-approval.test.ts b/packages/cli/test/service-restart-approval.test.ts index e9ad9f622f77..5593d247a3e9 100644 --- a/packages/cli/test/service-restart-approval.test.ts +++ b/packages/cli/test/service-restart-approval.test.ts @@ -14,8 +14,10 @@ type StubState = { activeFails: boolean requests: number resolves: Array + resolveFirstFails: boolean winner: RestartApprovalDecision | undefined sseConnections: number + order: Array } function startStub(initial: Partial = {}) { @@ -25,25 +27,27 @@ function startStub(initial: Partial = {}) { activeFails: false, requests: 0, resolves: [], + resolveFirstFails: false, winner: undefined, sseConnections: 0, + order: [], ...initial, } const controllers = new Set>() - const broadcast = (event: unknown) => { - const chunk = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) - for (const controller of controllers) { - try { - controller.enqueue(chunk) - } catch { - controllers.delete(controller) - } + const send = (controller: ReadableStreamDefaultController, event: unknown) => { + try { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)) + } catch { + controllers.delete(controller) } } + const broadcast = (event: unknown) => { + for (const controller of [...controllers]) send(controller, event) + } const resolve = (decision: RestartApprovalDecision) => { if (state.winner === undefined) { state.winner = decision - broadcast({ type: "server.restart.resolved", data: { decision: state.winner } }) + broadcast({ id: "evt_resolved", type: "server.restart.resolved", created: 1, data: { decision } }) } return { decision: state.winner } } @@ -61,18 +65,24 @@ function startStub(initial: Partial = {}) { } if (request.method === "POST" && url.pathname === "/api/server/restart/request") { state.requests += 1 + state.order.push("request") return Response.json({ active: Object.keys(state.active) }) } if (request.method === "POST" && url.pathname === "/api/server/restart/resolve") { const body = (await request.json()) as { decision: RestartApprovalDecision } state.resolves.push(body.decision) + if (state.resolveFirstFails && state.resolves.length === 1) { + return Response.json({ _tag: "InvalidRequestError", message: "No pending service restart request" }, { status: 400 }) + } return Response.json(resolve(body.decision)) } if (request.method === "GET" && url.pathname === "/api/event") { const stream = new ReadableStream({ start: (controller) => { state.sseConnections += 1 + state.order.push("sse") controllers.add(controller) + send(controller, { id: "evt_connected", type: "server.connected", created: 0, data: {} }) }, cancel: (controller) => { controllers.delete(controller) @@ -144,11 +154,26 @@ test("active sessions with --force restart without requesting approval", async ( } }) +test("the event subscription connects before the intent request", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps({ resolveTimeoutMs: 300 })) + await pending.then( + () => undefined, + () => undefined, + ) + expect(stub.state.order[0]).toBe("sse") + expect(stub.state.order).toContain("request") + } finally { + await stub.stop() + } +}) + test("non-TTY waits for a TUI decision and obeys the winner", async () => { const stub = startStub({ active: { ses_1: { type: "running" } } }) try { const pending = run(stub.client, deps()) - await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await until(() => stub.state.requests > 0, "CLI did not request approval") await tuiResolve(stub.url, "now") expect(await pending).toBe("now") expect(stub.state.requests).toBe(1) @@ -159,32 +184,35 @@ test("non-TTY waits for a TUI decision and obeys the winner", async () => { } }) -test("non-TTY timeout aborts and names --force", async () => { +test("non-TTY timeout aborts without naming --force and releases TUIs", async () => { const stub = startStub({ active: { ses_1: { type: "running" } } }) try { const error = await run(stub.client, deps({ resolveTimeoutMs: 200 })).then( () => undefined, (cause: Error) => cause, ) - expect(error?.message).toContain("--force") + expect(error?.message).toContain("Timed out") + expect(error?.message).not.toContain("--force") expect(stub.state.requests).toBe(1) - expect(stub.state.resolves).toEqual([]) + // The timeout posts a cancel so connected TUIs dismiss their dialogs. + expect(stub.state.resolves).toEqual(["cancel"]) } finally { await stub.stop() } }) -test("a TUI cancel aborts the restart", async () => { +test("a TUI cancel aborts the restart without naming --force", async () => { const stub = startStub({ active: { ses_1: { type: "running" } } }) try { const pending = run(stub.client, deps()) - await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await until(() => stub.state.requests > 0, "CLI did not request approval") await tuiResolve(stub.url, "cancel") const error = await pending.then( () => undefined, (cause: Error) => cause, ) - expect(error?.message).toContain("--force") + expect(error?.message).toContain("still running") + expect(error?.message).not.toContain("--force") } finally { await stub.stop() } @@ -198,7 +226,7 @@ test("an idle winner waits until sessions finish", async () => { stub.client, deps({ onWaiting: (active) => waited.push(active) }), ) - await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await until(() => stub.state.requests > 0, "CLI did not request approval") await tuiResolve(stub.url, "idle") expect(await pending).toBe("idle") expect(waited.length).toBeGreaterThan(0) @@ -207,11 +235,29 @@ test("an idle winner waits until sessions finish", async () => { } }) +test("the idle wait excludes the requesting session itself", async () => { + const previous = process.env.OPENCODE_SESSION_ID + process.env.OPENCODE_SESSION_ID = "ses_1" + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "idle") + expect(await pending).toBe("idle") + // One check plus one drain poll; without exclusion this would never settle. + expect(stub.state.activeCalls).toBeLessThanOrEqual(2) + } finally { + if (previous === undefined) delete process.env.OPENCODE_SESSION_ID + else process.env.OPENCODE_SESSION_ID = previous + await stub.stop() + } +}) + test("an idle wait aborts when the server connection drops", async () => { const stub = startStub({ active: { ses_1: { type: "running" } } }) try { const pending = run(stub.client, deps()) - await until(() => stub.state.sseConnections > 0, "CLI did not subscribe for approval") + await until(() => stub.state.requests > 0, "CLI did not request approval") await tuiResolve(stub.url, "idle") await until(() => stub.state.activeCalls > 1, "CLI did not poll for idle") stub.state.activeFails = true @@ -240,3 +286,19 @@ test("a TTY pick adopts the first-wins winner", async () => { await stub.stop() } }) + +test("a lost resolve race falls back to the broadcast winner", async () => { + const stub = startStub({ + active: { ses_1: { type: "running" } }, + drainAfterActiveCalls: 4, + resolveFirstFails: true, + }) + try { + const pending = run(stub.client, deps({ interactive: true })) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "idle") + expect(await pending).toBe("idle") + } finally { + await stub.stop() + } +}) diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts index 8818e0084daf..7bfefbadfa7f 100644 --- a/packages/server/src/handlers/server.ts +++ b/packages/server/src/handlers/server.ts @@ -46,12 +46,14 @@ export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handler .handle( "server.restart.resolve", Effect.fn(function* (ctx) { - const winner = yield* intent.resolve(ctx.payload.decision) - if (winner === undefined) { + const settled = yield* intent.resolve(ctx.payload.decision) + if (settled === undefined) { return yield* new InvalidRequestError({ message: "No pending service restart request" }) } - yield* bus.publish(ServiceEvent.Resolved, { decision: winner }, { global: true }) - return { decision: winner } + if (settled.fresh) { + yield* bus.publish(ServiceEvent.Resolved, { decision: settled.winner }, { global: true }) + } + return { decision: settled.winner } }), ) .handle( diff --git a/packages/server/src/restart-intent.ts b/packages/server/src/restart-intent.ts index c085155c1127..ce90bc2dba55 100644 --- a/packages/server/src/restart-intent.ts +++ b/packages/server/src/restart-intent.ts @@ -3,28 +3,34 @@ export * as RestartIntent from "./restart-intent" import { ServiceEvent } from "@opencode/schema/service-event" import { Context, Effect, Layer, Ref } from "effect" +type Settlement = { readonly winner: ServiceEvent.Decision; readonly fresh: boolean } | undefined +type IntentState = { readonly pending: boolean; readonly last?: ServiceEvent.Decision } + export class Service extends Context.Service Effect.Effect /** * Resolve the pending intent with first-wins semantics. Returns the winning - * decision, or undefined when no intent is pending. + * decision and whether this call settled it; late resolvers replay the + * winner without rebroadcasting. Returns undefined when no intent exists. */ - readonly resolve: (decision: ServiceEvent.Decision) => Effect.Effect + readonly resolve: (decision: ServiceEvent.Decision) => Effect.Effect }>()("@opencode/server/RestartIntent") {} export const layer = Layer.effect( Service, Effect.gen(function* () { - const state = yield* Ref.make<{ readonly pending: boolean; readonly last?: ServiceEvent.Decision }>({ - pending: false, - }) + const state = yield* Ref.make({ pending: false }) return Service.of({ request: () => Ref.update(state, (current) => (current.pending ? current : { pending: true })), resolve: (decision) => - Ref.modify(state, (current) => { - if (!current.pending) return [current.last, current] as const - return [decision, { pending: false, last: decision }] as const + Ref.modify(state, (current): readonly [Settlement, IntentState] => { + if (!current.pending) { + return current.last === undefined + ? [undefined, current] + : [{ winner: current.last, fresh: false }, current] + } + return [{ winner: decision, fresh: true }, { pending: false, last: decision }] }), }) }), diff --git a/packages/server/test/restart-intent.test.ts b/packages/server/test/restart-intent.test.ts new file mode 100644 index 000000000000..066c75c84195 --- /dev/null +++ b/packages/server/test/restart-intent.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { RestartIntent } from "../src/restart-intent" + +const run = (effect: Effect.Effect) => + Effect.runPromise(Effect.provide(effect, RestartIntent.layer)) + +test("first resolve wins and late resolvers replay it", async () => { + const winner = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + yield* intent.request() + const first = yield* intent.resolve("now") + const late = yield* intent.resolve("cancel") + return { first, late } + }), + ) + expect(winner.first).toEqual({ winner: "now", fresh: true }) + expect(winner.late).toEqual({ winner: "now", fresh: false }) +}) + +test("resolve with no pending intent returns undefined", async () => { + const winner = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + return yield* intent.resolve("now") + }), + ) + expect(winner).toBeUndefined() +}) + +test("a second request does not clobber the pending intent", async () => { + const winner = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + yield* intent.request() + yield* intent.request() + return yield* intent.resolve("idle") + }), + ) + expect(winner).toEqual({ winner: "idle", fresh: true }) +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ba92d2c39b74..69dca61c3b92 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1064,21 +1064,42 @@ function App() { await new Promise((resolve) => setTimeout(resolve, 2000)) } } - const waitForDecision = () => - new Promise<"now" | "idle" | "cancel">((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("Restart request expired with no approver")), 120000) - const dispose = event.on("server.restart.resolved", (evt) => { - clearTimeout(timer) - dispose() - resolve(evt.data.decision) - }) + const watchDecision = () => { + let armed = false + let pending: "now" | "idle" | "cancel" | undefined + let settle: ((decision: "now" | "idle" | "cancel") => void) | undefined + const dispose = event.on("server.restart.resolved", (evt) => { + if (!armed) return + pending ??= evt.data.decision + settle?.(evt.data.decision) }) + return { + arm() { + armed = true + }, + wait() { + return new Promise<"now" | "idle" | "cancel">((resolve, reject) => { + if (pending !== undefined) return resolve(pending) + const timer = setTimeout( + () => reject(new Error("Restart request expired with no approver")), + 120000, + ) + settle = (decision) => { + clearTimeout(timer) + resolve(decision) + } + }) + }, + dispose, + } + } const active = await client.api.session.active().catch(() => undefined) if (!active || Object.keys(active).length === 0) { await restartNow() return } let requested + const watch = watchDecision() try { requested = await client.api.server.restart.request() } catch { @@ -1090,6 +1111,7 @@ function App() { await restartNow() return } + watch.arm() if (Object.keys(requested.active).length === 0) { await restartNow() return @@ -1099,10 +1121,15 @@ function App() { message: `Waiting for restart approval (${Object.keys(requested.active).length} running)…`, duration: 120000, }) - const winner = await waitForDecision().catch(async () => { + let winner: "now" | "idle" | "cancel" + try { + winner = await watch.wait() + } catch { await client.api.server.restart.resolve({ decision: "cancel" }).catch(() => undefined) - return "cancel" as const - }) + winner = "cancel" + } finally { + watch.dispose() + } if (winner === "now") await restartNow() else if (winner === "idle") { toast.show({ variant: "info", message: "Restart queued; waiting for sessions to finish…" }) diff --git a/packages/tui/src/context/restart-approval.tsx b/packages/tui/src/context/restart-approval.tsx index 4c139eac6d09..dea9ebdace33 100644 --- a/packages/tui/src/context/restart-approval.tsx +++ b/packages/tui/src/context/restart-approval.tsx @@ -1,96 +1,78 @@ +import { For, createSignal } from "solid-js" import type { ServiceEvent } from "@opencode/schema/service-event" -import { createSignal, For, Show } from "solid-js" -import { SessionQuestion } from "../routes/session/permission" -import { useDialog } from "../ui/dialog" -import { useToast } from "../ui/toast" -import { errorMessage } from "../util/error" +import { createSimpleContext } from "./helper" import { useClient } from "./client" import { useData } from "./data" +import { useDialog } from "../ui/dialog" import { useEvent } from "./event" -import { createSimpleContext } from "./helper" +import { useToast } from "../ui/toast" +import { errorMessage } from "../util/error" +import { SessionQuestion } from "../routes/session/permission" -export const { use: useRestartApproval, provider: RestartApprovalProvider } = - createSimpleContext({ - name: "RestartApproval", - init: () => { - const client = useClient() - const dialog = useDialog() - const event = useEvent() - const toast = useToast() - const [active, setActive] = createSignal>([]) +export const { use: useRestartApproval, provider: RestartApprovalProvider } = createSimpleContext({ + name: "RestartApproval", + init: () => { + const client = useClient() + const dialog = useDialog() + const event = useEvent() + const toast = useToast() + const [active, setActive] = createSignal>([]) - function open(sessions: ReadonlyArray) { - setActive(sessions) - dialog.replace(() => ( - void decide(decision)} - /> - )) - } + function open(sessions: ReadonlyArray) { + setActive(sessions) + dialog.replace(() => void decide(decision)} />) + } - function close() { - setActive([]) - dialog.clear() - } + function close() { + setActive([]) + dialog.clear() + } - async function decide(decision: ServiceEvent.Decision) { - try { - await client.api.server.restart.resolve({ decision }) - } catch (error) { - toast.show({ - title: "Failed to resolve restart", - message: errorMessage(error), - variant: "error", - }) - } - close() - } + async function decide(decision: ServiceEvent.Decision) { + try { + await client.api.server.restart.resolve({ decision }) + } catch (error) { + // Someone else settled the intent first; their resolved event dismisses us. + if (!(error instanceof Error && error.name === "InvalidRequestError")) { + toast.show({ title: "Failed to resolve restart", message: errorMessage(error), variant: "error" }) + } + } + close() + } - event.on("server.restart.requested", (evt) => open(evt.data.active)) - event.on("server.restart.resolved", () => close()) + event.on("server.restart.requested", (evt) => open(evt.data.active)) + event.on("server.restart.resolved", () => close()) - return { open, close, decide } - }, - }) + return { open, close, decide } + }, +}) function RestartApprovalDialog(props: { - active: ReadonlyArray - onSelect: (decision: ServiceEvent.Decision) => void + active: ReadonlyArray + onSelect: (decision: ServiceEvent.Decision) => void }) { - const data = useData() + const data = useData() - return ( - - - The following sessions have running agents and would be interrupted: - - - - {(sessionID) => ( - - {data.session.get(sessionID)?.title ?? sessionID} - )} - - - - No sessions are running anymore. - - - } - options={{ - now: "Restart now", - idle: "When idle", - cancel: "Cancel restart", - }} - escapeKey="cancel" - onSelect={(option) => props.onSelect(option)} - /> - ) + return ( + + The following sessions have running agents and would be interrupted: + + + {(sessionID) => - {data.session.get(sessionID)?.title ?? sessionID}} + + + + } + options={{ now: "Restart now", idle: "When idle", cancel: "Cancel restart" }} + escapeKey="cancel" + onSelect={(option) => props.onSelect(option)} + /> + ) } diff --git a/packages/tui/test/cli/tui/restart-approval.test.tsx b/packages/tui/test/cli/tui/restart-approval.test.tsx index 49f3fc3c1ad4..a34b27949b16 100644 --- a/packages/tui/test/cli/tui/restart-approval.test.tsx +++ b/packages/tui/test/cli/tui/restart-approval.test.tsx @@ -5,143 +5,145 @@ import { ConfigProvider } from "../../../src/config" import { ClientProvider } from "../../../src/context/client" import { DataProvider, useData } from "../../../src/context/data" import { Keymap } from "../../../src/context/keymap" -import { - RestartApprovalProvider, - useRestartApproval, -} from "../../../src/context/restart-approval" +import { RestartApprovalProvider, useRestartApproval } from "../../../src/context/restart-approval" import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" import { emptyThemeSource } from "../../fixture/fixture" -import { - createApi, - createEventStream, - createFetch, - json, -} from "../../fixture/tui-client" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" const directory = "/tmp/opencode/restart-approval" +const title = "Restart service with running agents?" test("restart request opens approval dialog and deciding resolves the intent", async () => { - const fixture = await renderApproval() - try { - await fixture.data.session.sync("ses_1") - fixture.events.emit({ - id: "evt_restart_requested", - type: "server.restart.requested", - created: 1, - data: { active: ["ses_1", "ses_2"] }, - }) - await fixture.app.waitForFrame((frame) => - frame.includes("Restart service with running agents?"), - ) - const frame = await fixture.app.waitForFrame((frame) => - frame.includes("Lane one"), - ) - expect(frame).toContain("ses_2") + const fixture = await renderApproval() + try { + await fixture.data.session.sync("ses_1") + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1", "ses_2"] }, + }) + const frame = await fixture.app.waitForFrame((frame) => frame.includes(title) && frame.includes("Lane one")) + expect(frame).toContain("ses_2") - await fixture.approval.decide("idle") + await fixture.approval.decide("idle") - await fixture.app.waitFor(() => fixture.resolves.length === 1) - expect(fixture.resolves).toEqual([{ decision: "idle" }]) - } finally { - fixture.app.renderer.destroy() - } + await fixture.app.waitFor(() => fixture.resolves.length === 1) + expect(fixture.resolves).toEqual([{ decision: "idle" }]) + } finally { + fixture.app.renderer.destroy() + } }) test("resolution from another surface dismisses the dialog", async () => { - const fixture = await renderApproval() - try { - fixture.events.emit({ - id: "evt_restart_requested", - type: "server.restart.requested", - created: 1, - data: { active: ["ses_1"] }, - }) - await fixture.app.waitForFrame((frame) => - frame.includes("Restart service with running agents?"), - ) - fixture.events.emit({ - id: "evt_restart_resolved", - type: "server.restart.resolved", - created: 2, - data: { decision: "now" }, - }) - await fixture.app.waitForFrame( - (frame) => !frame.includes("Restart service with running agents?"), - ) - expect(fixture.resolves).toEqual([]) - } finally { - fixture.app.renderer.destroy() - } + const fixture = await renderApproval() + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => frame.includes(title)) + fixture.events.emit({ + id: "evt_restart_resolved", + type: "server.restart.resolved", + created: 2, + data: { decision: "now" }, + }) + await fixture.app.waitForFrame((frame) => !frame.includes(title)) + expect(fixture.resolves).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } }) -async function renderApproval() { - const resolves: unknown[] = [] - const events = createEventStream() - const calls = createFetch(async (url, request) => { - if (url.pathname === "/api/server/restart/resolve") { - const payload = await request.json() - resolves.push(payload) - return json({ decision: payload.decision }) - } - if (url.pathname === "/api/session/ses_1") { - return json({ - data: { - id: "ses_1", - projectID: "proj_test", - location: { directory }, - title: "Lane one", - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - time: { created: 1, updated: 1 }, - }, - }) - } - return undefined - }, events) - let data!: ReturnType - let approval!: ReturnType +test("a stale resolve dismisses silently without an error toast", async () => { + const fixture = await renderApproval({ resolveFails: true }) + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => frame.includes(title)) - function Probe() { - data = useData() - approval = useRestartApproval() - return null - } + await fixture.approval.decide("now") - const app = await testRender( - () => ( - - - - - - - - - - - - - - - - - - - - ), - { width: 100, height: 30, kittyKeyboard: true }, - ) - app.renderer.start() - await app.waitFor(() => approval !== undefined) + await fixture.app.waitFor(() => fixture.resolves.length === 1) + const frame = await fixture.app.waitForFrame((frame) => !frame.includes(title)) + expect(frame).not.toContain("Failed to resolve restart") + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderApproval(input: { resolveFails?: boolean } = {}) { + const resolves: unknown[] = [] + const events = createEventStream() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/server/restart/resolve") { + const payload = await request.json() + resolves.push(payload) + if (input.resolveFails) + return json({ _tag: "InvalidRequestError", message: "No pending service restart request" }, { status: 400 }) + return json({ decision: payload.decision }) + } + if (url.pathname === "/api/session/ses_1") { + return json({ + data: { + id: "ses_1", + projectID: "proj_test", + location: { directory }, + title: "Lane one", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + }, + }) + } + return undefined + }, events) + let data!: ReturnType + let approval!: ReturnType + + function Probe() { + data = useData() + approval = useRestartApproval() + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitFor(() => approval !== undefined) - return { app, data, approval, events, resolves } + return { app, data, approval, events, resolves } } From 57f3ddfaff013818fcac6bf8344377ef8eae1c18 Mon Sep 17 00:00:00 2001 From: agustif Date: Fri, 16 Oct 2026 08:21:17 +0200 Subject: [PATCH 6/6] fix(server): expire unanswered restart requests A requester that is interrupted or SIGKILLed (for example by the agent shell tool's own timeout) never reaches its release path, so the pending intent lived forever and every approver dialog stayed open, with a later decision broadcast to nobody. The server now owns release: each request schedules a detached expiry that clears the intent and emits server.restart.expired, which approvers treat as a dismissal. Generation tokens keep a stale timer from expiring a newer request. The CLI no longer posts its own cancel; expiry is the single release path, and its local wait is longer than the server TTL so the server always ends an unanswered wait. (cherry picked from commit 8a5d47cb58ba5ea400a5e0ccc77c8aeac7c7834a) Co-authored-by: Ivan Milev --- .../src/commands/handlers/service/restart.ts | 75 ++++++++++--------- .../cli/test/service-restart-approval.test.ts | 31 +++++++- .../client/src/promise/generated/types.ts | 10 +++ packages/schema/src/service-event.ts | 9 ++- packages/server/src/handlers/server.ts | 16 +++- packages/server/src/restart-intent.ts | 39 ++++++++-- packages/server/test/restart-intent.test.ts | 40 ++++++++++ packages/tui/src/context/restart-approval.tsx | 1 + .../test/cli/tui/restart-approval.test.tsx | 23 ++++++ 9 files changed, 196 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index d6e7a141d7bb..59af107699f9 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -9,6 +9,12 @@ import { ServiceConfig } from "../../../services/service-config" import { ServerConnection } from "../../../services/server-connection" import { prompt } from "../../../ui/prompt" +// Waits longer than the server's own request TTL so expiry, not this local +// timer, is what ends an unanswered wait. The timer only covers a stream that +// goes silent without the server releasing the request. +const ApprovalWaitMs = 150_000 +const ApprovalTimeoutMessage = "Timed out waiting for restart approval with sessions still running." + export default Runtime.handler( Commands.commands.service.commands.restart, Effect.fn("cli.service.restart")(function* (input: Runtime.Input) { @@ -20,7 +26,7 @@ export default Runtime.handler( force: input.force, interactive: process.stdin.isTTY === true && process.stdout.isTTY === true, ask: askRestartDecision, - resolveTimeoutMs: 120_000, + resolveTimeoutMs: ApprovalWaitMs, idlePollMs: 2_000, onWaiting: (active) => process.stdout.write(`Waiting for ${active.length} active session(s) to finish...${EOL}`), @@ -47,10 +53,11 @@ export type RestartApprovalDeps = { // Approval gate for restarting a managed server with running sessions. The event // subscription starts before the intent request so a resolution cannot slip -// through between them; only decisions recorded after the request count. -// Resolves to the winning decision ("now" when nothing needs approval); "cancel" -// and approval timeouts fail instead of restarting. A local pick is adopted from -// the resolve response, falling back to the broadcast winner on a lost race. +// through between them; only decisions recorded after the request count. The +// server owns release: it expires an unanswered request, so a requester that +// dies without unwinding still closes approver dialogs. Resolves to the winning +// decision ("now" when nothing needs approval); "cancel", expiry and a dead +// stream fail instead of restarting. export const resolveRestartApproval = Effect.fn("cli.service.restart.approval")(function* ( client: OpenCodeClient, deps: RestartApprovalDeps, @@ -71,7 +78,7 @@ export const resolveRestartApproval = Effect.fn("cli.service.restart.approval")( watcher.arm() const winner = deps.interactive ? yield* resolveInteractive(client, deps, watcher, ids) - : yield* resolveRemote(client, deps, watcher) + : yield* awaitWinner(watcher, deps) if (winner === "cancel") return yield* Effect.fail(restartAborted(ids, deps.interactive)) if (winner === "idle") yield* waitForIdle(client, deps) return winner @@ -93,36 +100,25 @@ const resolveInteractive = Effect.fn("cli.service.restart.approval.interactive") }).pipe( Effect.map((resolved) => resolved.decision), // Another surface won the race; adopt the broadcast winner instead. - Effect.catchIf(isInvalidRequestError, () => awaitWinner(client, watcher, deps)), + Effect.catchIf(isInvalidRequestError, () => awaitWinner(watcher, deps)), Effect.mapError((cause) => new Error("Could not reach server to resolve restart approval", { cause })), ) }) -const resolveRemote = Effect.fn("cli.service.restart.approval.remote")(function* ( - client: OpenCodeClient, - deps: RestartApprovalDeps, - watcher: ResolvedWatcher, -) { - return yield* awaitWinner(client, watcher, deps) -}) - const awaitWinner = Effect.fn("cli.service.restart.approval.wait")(function* ( - client: OpenCodeClient, watcher: ResolvedWatcher, deps: RestartApprovalDeps, ) { - return yield* Effect.tryPromise({ + const outcome = yield* Effect.tryPromise({ try: () => watcher.wait(deps.resolveTimeoutMs), catch: (cause) => cause instanceof RestartApprovalTimeout - ? new Error("Timed out waiting for restart approval with sessions still running.") + ? new Error(ApprovalTimeoutMessage) : new Error("Lost connection to the server while waiting for restart approval", { cause }), - }).pipe( - Effect.tapError(() => - // Release TUIs stuck on our intent; a settled intent replays its winner. - Effect.tryPromise(() => client.server.restart.resolve({ decision: "cancel" })).pipe(Effect.ignore), - ), - ) + }) + // The server expired the request without a decision; nothing was approved. + if (outcome === "expired") return yield* Effect.fail(new Error(ApprovalTimeoutMessage)) + return outcome }) // The requesting session blocks on this command, so it can never go idle while @@ -159,17 +155,21 @@ class RestartApprovalTimeout extends Error { } } +export type RestartResolution = RestartApprovalDecision | "expired" + export type ResolvedWatcher = { readonly arm: () => void - readonly wait: (timeoutMs: number) => Promise + readonly wait: (timeoutMs: number) => Promise readonly dispose: () => void } +// Subscribes immediately so a decision broadcast during the intent request is +// buffered rather than missed; consumers only count outcomes seen after arm(). export function watchResolved(client: OpenCodeClient): ResolvedWatcher { const controller = new AbortController() let armed = false - let pending: RestartApprovalDecision | undefined - let settle: ((decision: RestartApprovalDecision) => void) | undefined + let pending: RestartResolution | undefined + let settle: ((outcome: RestartResolution) => void) | undefined let failure: unknown | undefined const fail = (cause: unknown) => { failure ??= cause @@ -181,11 +181,16 @@ export function watchResolved(client: OpenCodeClient): ResolvedWatcher { for (;;) { const next = await stream.next() if (next.done) break - if (armed && next.value.type === "server.restart.resolved") { - const decision = next.value.data.decision - pending ??= decision - settle?.(decision) - } + if (!armed) continue + const outcome: RestartResolution | undefined = + next.value.type === "server.restart.resolved" + ? next.value.data.decision + : next.value.type === "server.restart.expired" + ? "expired" + : undefined + if (outcome === undefined) continue + pending ??= outcome + settle?.(outcome) } fail(new Error("Event stream disconnected while waiting for restart approval")) } catch (cause) { @@ -197,13 +202,13 @@ export function watchResolved(client: OpenCodeClient): ResolvedWatcher { armed = true }, wait(timeoutMs: number) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (pending !== undefined) return resolve(pending) if (failure !== undefined) return reject(failure) const timer = setTimeout(() => reject(new RestartApprovalTimeout()), timeoutMs) - settle = (decision) => { + settle = (outcome) => { clearTimeout(timer) - resolve(decision) + resolve(outcome) } }) }, diff --git a/packages/cli/test/service-restart-approval.test.ts b/packages/cli/test/service-restart-approval.test.ts index 5593d247a3e9..74e44df9f843 100644 --- a/packages/cli/test/service-restart-approval.test.ts +++ b/packages/cli/test/service-restart-approval.test.ts @@ -100,7 +100,13 @@ function startStub(initial: Partial = {}) { }, }) const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${server.port}`, headers: {} }) - return { state, client, url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) } + return { + state, + client, + url: `http://127.0.0.1:${server.port}`, + broadcastExpired: () => broadcast({ id: "evt_expired", type: "server.restart.expired", created: 2, data: {} }), + stop: () => server.stop(true), + } } const deps = (overrides: Partial = {}): RestartApprovalDeps => ({ @@ -184,7 +190,7 @@ test("non-TTY waits for a TUI decision and obeys the winner", async () => { } }) -test("non-TTY timeout aborts without naming --force and releases TUIs", async () => { +test("non-TTY timeout aborts without naming --force and leaves release to the server", async () => { const stub = startStub({ active: { ses_1: { type: "running" } } }) try { const error = await run(stub.client, deps({ resolveTimeoutMs: 200 })).then( @@ -194,8 +200,25 @@ test("non-TTY timeout aborts without naming --force and releases TUIs", async () expect(error?.message).toContain("Timed out") expect(error?.message).not.toContain("--force") expect(stub.state.requests).toBe(1) - // The timeout posts a cancel so connected TUIs dismiss their dialogs. - expect(stub.state.resolves).toEqual(["cancel"]) + // Release is the server's job; the CLI does not post a cancel of its own. + expect(stub.state.resolves).toEqual([]) + } finally { + await stub.stop() + } +}) + +test("an expired request aborts the wait", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + stub.broadcastExpired() + const error = await pending.then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("Timed out") + expect(error?.message).not.toContain("--force") } finally { await stub.stop() } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index da6980b61eb5..21efd2c4ce77 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1759,6 +1759,15 @@ export type ServerRestartResolved = { data: { decision: "now" | "idle" | "cancel" } } +export type ServerRestartExpired = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "server.restart.expired" + location?: LocationRef + data: {} +} + export type TuiPromptAppend = { id: EventID created: number @@ -2647,6 +2656,7 @@ export type V2Event = | SessionIdle | ServerRestartRequested | ServerRestartResolved + | ServerRestartExpired | TuiPromptAppend | TuiCommandExecute | TuiToastShow diff --git a/packages/schema/src/service-event.ts b/packages/schema/src/service-event.ts index 00c378378a4e..8ce84beb234a 100644 --- a/packages/schema/src/service-event.ts +++ b/packages/schema/src/service-event.ts @@ -25,4 +25,11 @@ export const Resolved = Event.ephemeral({ }, }) -export const Definitions = Event.inventory(Requested, Resolved) +// Emitted when a pending request passes its deadline without being resolved, so +// approvers can dismiss it even if the requester died without releasing. +export const Expired = Event.ephemeral({ + type: "server.restart.expired", + schema: {}, +}) + +export const Definitions = Event.inventory(Requested, Resolved, Expired) diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts index 7bfefbadfa7f..e03f25ebdfa0 100644 --- a/packages/server/src/handlers/server.ts +++ b/packages/server/src/handlers/server.ts @@ -12,6 +12,10 @@ import { ServerInfo } from "../server-info" import { ServerPairing } from "../pairing" import { RestartIntent } from "../restart-intent" +// Deadline for a pending restart request. Kept in step with the CLI's own +// approval wait; the requester releases first when it is alive. +const RestartApprovalTtl = Duration.minutes(2) + export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) => Effect.gen(function* () { const pairing = yield* ServerPairing.Service @@ -38,8 +42,18 @@ export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handler "server.restart.request", Effect.fn(function* () { const active = Array.from(yield* session.active) - yield* intent.request() + const generation = yield* intent.request() yield* bus.publish(ServiceEvent.Requested, { active }, { global: true }) + // Backstop for a requester that dies without releasing: the requester + // covers its own timeout, this covers SIGKILL and shell-tool kills. + // Detached so it outlives this request's scope. + yield* Effect.sleep(RestartApprovalTtl).pipe( + Effect.andThen(intent.expire(generation)), + Effect.flatMap((expired) => + expired ? bus.publish(ServiceEvent.Expired, {}, { global: true }) : Effect.void, + ), + Effect.forkDetach, + ) return { active } }), ) diff --git a/packages/server/src/restart-intent.ts b/packages/server/src/restart-intent.ts index ce90bc2dba55..c6f6c369d3a2 100644 --- a/packages/server/src/restart-intent.ts +++ b/packages/server/src/restart-intent.ts @@ -3,26 +3,46 @@ export * as RestartIntent from "./restart-intent" import { ServiceEvent } from "@opencode/schema/service-event" import { Context, Effect, Layer, Ref } from "effect" -type Settlement = { readonly winner: ServiceEvent.Decision; readonly fresh: boolean } | undefined -type IntentState = { readonly pending: boolean; readonly last?: ServiceEvent.Decision } +export type Settlement = { readonly winner: ServiceEvent.Decision; readonly fresh: boolean } | undefined + +type IntentState = { + readonly generation: number + readonly pending: boolean + readonly last?: ServiceEvent.Decision +} export class Service extends Context.Service Effect.Effect + /** + * Record a pending intent unless one exists. Returns the generation token for + * this request, used to expire it without disturbing a later one. + */ + readonly request: () => Effect.Effect /** * Resolve the pending intent with first-wins semantics. Returns the winning * decision and whether this call settled it; late resolvers replay the * winner without rebroadcasting. Returns undefined when no intent exists. */ readonly resolve: (decision: ServiceEvent.Decision) => Effect.Effect + /** + * Drop the pending intent when it is still the given generation. Returns + * whether this call expired it; a stale token or a settled intent is a no-op. + */ + readonly expire: (generation: number) => Effect.Effect }>()("@opencode/server/RestartIntent") {} export const layer = Layer.effect( Service, Effect.gen(function* () { - const state = yield* Ref.make({ pending: false }) + const state = yield* Ref.make({ generation: 0, pending: false }) return Service.of({ - request: () => Ref.update(state, (current) => (current.pending ? current : { pending: true })), + request: () => + Ref.modify( + state, + (current): readonly [number, IntentState] => { + const generation = current.generation + 1 + return [generation, current.pending ? { ...current, generation } : { generation, pending: true }] + }, + ), resolve: (decision) => Ref.modify(state, (current): readonly [Settlement, IntentState] => { if (!current.pending) { @@ -30,7 +50,12 @@ export const layer = Layer.effect( ? [undefined, current] : [{ winner: current.last, fresh: false }, current] } - return [{ winner: decision, fresh: true }, { pending: false, last: decision }] + return [{ winner: decision, fresh: true }, { ...current, pending: false, last: decision }] + }), + expire: (generation) => + Ref.modify(state, (current): readonly [boolean, IntentState] => { + if (!current.pending || current.generation !== generation) return [false, current] + return [true, { ...current, pending: false }] }), }) }), diff --git a/packages/server/test/restart-intent.test.ts b/packages/server/test/restart-intent.test.ts index 066c75c84195..9b43b34e80cf 100644 --- a/packages/server/test/restart-intent.test.ts +++ b/packages/server/test/restart-intent.test.ts @@ -40,3 +40,43 @@ test("a second request does not clobber the pending intent", async () => { ) expect(winner).toEqual({ winner: "idle", fresh: true }) }) + +test("expiring the current generation releases the intent", async () => { + const outcome = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + const generation = yield* intent.request() + const expired = yield* intent.expire(generation) + return { expired, settled: yield* intent.resolve("now") } + }), + ) + expect(outcome.expired).toBe(true) + expect(outcome.settled).toBeUndefined() +}) + +test("a stale expiry token leaves a newer request pending", async () => { + const outcome = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + const stale = yield* intent.request() + yield* intent.request() + const expired = yield* intent.expire(stale) + return { expired, settled: yield* intent.resolve("idle") } + }), + ) + // Without the token guard the timer for the first request would free the second. + expect(outcome.expired).toBe(false) + expect(outcome.settled).toEqual({ winner: "idle", fresh: true }) +}) + +test("expiring a settled intent is a no-op", async () => { + const outcome = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + const generation = yield* intent.request() + yield* intent.resolve("cancel") + return yield* intent.expire(generation) + }), + ) + expect(outcome).toBe(false) +}) diff --git a/packages/tui/src/context/restart-approval.tsx b/packages/tui/src/context/restart-approval.tsx index dea9ebdace33..c159a8cceae7 100644 --- a/packages/tui/src/context/restart-approval.tsx +++ b/packages/tui/src/context/restart-approval.tsx @@ -42,6 +42,7 @@ export const { use: useRestartApproval, provider: RestartApprovalProvider } = cr event.on("server.restart.requested", (evt) => open(evt.data.active)) event.on("server.restart.resolved", () => close()) + event.on("server.restart.expired", () => close()) return { open, close, decide } }, diff --git a/packages/tui/test/cli/tui/restart-approval.test.tsx b/packages/tui/test/cli/tui/restart-approval.test.tsx index a34b27949b16..f27d0620b5e9 100644 --- a/packages/tui/test/cli/tui/restart-approval.test.tsx +++ b/packages/tui/test/cli/tui/restart-approval.test.tsx @@ -62,6 +62,29 @@ test("resolution from another surface dismisses the dialog", async () => { } }) +test("an expired request dismisses the dialog", async () => { + const fixture = await renderApproval() + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => frame.includes(title)) + fixture.events.emit({ + id: "evt_restart_expired", + type: "server.restart.expired", + created: 2, + data: {}, + }) + await fixture.app.waitForFrame((frame) => !frame.includes(title)) + expect(fixture.resolves).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) + test("a stale resolve dismisses silently without an error toast", async () => { const fixture = await renderApproval({ resolveFails: true }) try {