Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
752be37
fix(opencode): updating moves the running server onto the new binary
mkantenko Nov 4, 2026
464e8fa
fix(opencode): replace only the updated instance's service
mkantenko Nov 4, 2026
a88a8ac
fix(opencode): leave the 1.x helper out of the service fix
mkantenko Nov 4, 2026
8e9ae74
fix(opencode): never send the local service password to the configure…
mkantenko Nov 4, 2026
caede3c
fix(opencode): report a failed service replacement as a failed update…
mkantenko Nov 4, 2026
3fed040
fix(opencode): match the service URL by address family and keep stder…
mkantenko Nov 4, 2026
fbee882
fix(opencode): compare URL protocols and keep the replacement error's…
mkantenko Nov 4, 2026
95c6ead
fix(opencode): keep the replaced service independent of T3 Code
mkantenko Nov 4, 2026
d9576a5
docs(opencode): name the Server URLs that restart the service on update
mkantenko Nov 5, 2026
12987a0
Merge remote-tracking branch 'origin/main' into t3/opencode-service-u…
mkantenko Nov 10, 2026
526e6c8
fix(opencode): reconnect after replacing the service so its new versi…
mkantenko Nov 10, 2026
c16df60
fix(opencode): forget every instance's service connection when an upd…
mkantenko Nov 10, 2026
8ed7062
Merge remote-tracking branch 'github/main' into pr-31152
maria-rcks Nov 10, 2026
ae5ebed
fix(opencode): replace the shared service before any instance forgets…
maria-rcks Nov 10, 2026
c7e2cb5
Merge github/main into t3/opencode-service-update
maria-rcks Nov 11, 2026
f719da1
Merge remote-tracking branch 'origin/main' into t3/opencode-service-u…
t3dotgg Nov 11, 2026
df1a7e5
fix(opencode): a failed service replacement still reports what is run…
t3dotgg Nov 11, 2026
f83e025
Merge remote-tracking branch 'origin/main' into t3/opencode-service-u…
t3dotgg Nov 11, 2026
acdd2fb
Merge remote-tracking branch 'origin/main' into t3/opencode-service-u…
t3dotgg Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export const replayServer = (
borrowers.current -= 1;
}),
),
forget: Effect.void,
});
});

Expand Down
49 changes: 47 additions & 2 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import type {
import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts";
import * as ProviderRegistry from "./ProviderRegistry.ts";
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
Expand Down Expand Up @@ -1703,6 +1704,8 @@ it.layer(
Effect.gen(function* () {
const forceRefreshCalls = yield* Ref.make(0);
const manifest = yield* Ref.make(ModelManifest.BUNDLED_MODEL_MANIFEST);
const updateEvents = yield* Ref.make<ReadonlyArray<string>>([]);
const hookFails = yield* Ref.make(false);
const makeInstance = (name: string) =>
Effect.gen(function* () {
const driver = ProviderDriverKind.make("codex");
Expand All @@ -1722,7 +1725,7 @@ it.layer(
} as const satisfies ServerProvider;
const invalidations = yield* Ref.make(0);
const probes = yield* Ref.make(0);
const maintenanceReads = yield* Ref.make<ReadonlyArray<boolean>>([]);
const maintenanceReads = yield* Ref.make<ReadonlyArray<boolean | "afterUpdate">>([]);
const instance = {
instanceId,
driverKind: driver,
Expand All @@ -1747,7 +1750,25 @@ it.layer(
streamChanges: Stream.empty,
applyUsageLimits: () => Effect.void,
},
invalidateCaches: Ref.update(invalidations, (count) => count + 1),
invalidateCaches: Ref.update(invalidations, (count) => count + 1).pipe(
Effect.andThen(
Ref.update(updateEvents, (events) => [...events, `invalidate:${name}`]),
),
),
afterUpdate: Ref.update(maintenanceReads, (reads) => [
...reads,
"afterUpdate" as const,
]).pipe(
Effect.andThen(
Ref.update(updateEvents, (events) => [...events, `afterUpdate:${name}`]),
),
Effect.andThen(Ref.get(hookFails)),
Effect.flatMap((fails) =>
fails
? Effect.fail(new ProviderDriverError({ driver, instanceId, detail: "hook" }))
: Effect.void,
),
),
orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
} satisfies ProviderInstance;
Expand Down Expand Up @@ -1835,6 +1856,30 @@ it.layer(
assert.strictEqual(yield* Ref.get(second.probes), probesBefore + 1);
// A changed manifest also rechecks latest CLI versions.
assert.strictEqual(yield* readCachedVersion("codex_second-package", "3.0.0"), "3.0.0");

yield* first.takeMaintenanceReads;
yield* Ref.set(updateEvents, []);
yield* registry.refreshAfterUpdate(first.instance.driverKind, first.instance.instanceId);
assert.deepStrictEqual((yield* first.takeMaintenanceReads).slice(0, 2), [
"afterUpdate",
true,
]);
assert.notInclude(yield* second.takeMaintenanceReads, "afterUpdate");
// Instances sharing the replaced service must not cache its old version.
assert.strictEqual((yield* Ref.get(updateEvents))[0], "afterUpdate:codex_first");

// A failed hook leaves the probe to report what is running.
yield* Ref.set(hookFails, true);
const probesBeforeFailedHook = yield* Ref.get(first.probes);
const refreshed = yield* registry.refreshAfterUpdate(
first.instance.driverKind,
first.instance.instanceId,
);
assert.strictEqual(yield* Ref.get(first.probes), probesBeforeFailedHook + 1);
assert.include(
refreshed.map((provider) => provider.instanceId),
first.instance.instanceId,
);
}).pipe(
Effect.provide(
ProviderRegistry.layer.pipe(
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,23 @@ export const layer = Layer.effect(
),
);
});
// Instances can share the replaced service, so none may invalidate before it restarts.
const selectedInstance = instances.find(
(instance) => instance.instanceId === selectedInstanceId,
);
if (selectedInstance?.afterUpdate !== undefined) {
// The probe below reports what is running, so a failed hook needs no status of its own.
yield* selectedInstance.afterUpdate.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.logError("provider post-update hook failed", {
instanceId: selectedInstance.instanceId,
cause: Cause.pretty(cause),
Comment thread
t3dotgg marked this conversation as resolved.
Resolved
}),
),
);
}
// Accounts can share an installation, but discovery must use each
// instance's own executable, environment, and credentials.
yield* Effect.forEach(
Expand Down
16 changes: 12 additions & 4 deletions docs/user/providers-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,18 @@ does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version ch
fail, check the URL, credentials, and OpenCode version, then refresh provider status.

When **Server URL** points to a locally running server (such as `127.0.0.1`),
the update button upgrades its installed CLI without terminating the running server.
The server can still report the previous version until you restart it. T3 Code
shows that restart requirement instead of treating the CLI update as a failure.
Restart the server when active sessions can safely be interrupted.
the update button upgrades its installed CLI. If that server is this machine's
OpenCode background service, T3 Code then restarts it on the new version. The URL
must use the service's own protocol, host, and port. For a service listening on
`0.0.0.0` use `127.0.0.1`, and for `[::]` use `[::1]`.

Restarting interrupts running turns, and the service then runs with T3 Code's
environment. Set any variables it needs with `opencode service set env <name> <value>`.

T3 Code does not restart any other server, including the background service
reached through `localhost`. That server can still report the previous version
until you restart it. For a loopback URL, T3 Code shows that restart requirement
instead of treating the CLI update as a failure.

After a lost connection, send another prompt to reconnect to the same OpenCode
session.
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-core/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface ProviderInstance {
) => Effect.Effect<ProviderWorkspaceSnapshot, ProviderDriverError>;
readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
readonly invalidateCaches?: Effect.Effect<void>;
/** Moves a long-lived service onto a newly updated binary before its version is read back. */
readonly afterUpdate?: Effect.Effect<void, ProviderDriverError>;
readonly consumeResetCredit?: (
creditId: string | undefined,
) => Effect.Effect<ProviderConsumeResetCreditOutcome, ProviderDriverError>;
Expand Down
30 changes: 30 additions & 0 deletions packages/provider-opencode/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { makeOpenCodeTextGeneration } from "./textGeneration.ts";
import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import { AgentScope } from "@t3tools/shared/AgentScope";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { compareSemverVersions } from "@t3tools/shared/semver";
import * as OpenCodeAdapterV2 from "./adapter.ts";
Expand Down Expand Up @@ -56,6 +57,7 @@ import {
import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
import * as OpenCode2Server from "./v2/OpenCode2Server.ts";
import { replaceOutdatedService } from "./v2/OpenCode2Service.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
Expand Down Expand Up @@ -267,6 +269,7 @@ export const OpenCodeDriver: ProviderDriver<
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
const agentScope = yield* AgentScope;
const httpClient = yield* HttpClient.HttpClient;
const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
const digest = yield* Digest.Digest;
Expand Down Expand Up @@ -577,6 +580,31 @@ export const OpenCodeDriver: ProviderDriver<
),
);

const serverUrl = effectiveConfig.serverUrl.trim();
const afterUpdate =
serverUrl.length > 0
? replaceOutdatedService({
binaryPath: effectiveConfig.binaryPath,
serverUrl,
environment: processEnv,
}).pipe(
Effect.ensuring(openCode2Server.forget),
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: "Failed to replace the OpenCode service after an update.",
cause,
}),
),
Effect.provideService(AgentScope, agentScope),
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, pathService),
)
: Effect.void;

const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>({
resolveMaintenance,
Expand Down Expand Up @@ -709,6 +737,8 @@ export const OpenCodeDriver: ProviderDriver<
}),
),
),
invalidateCaches: openCode2Server.forget,
afterUpdate,
orchestrationAdapter,
textGeneration,
} satisfies ProviderInstance;
Expand Down
45 changes: 45 additions & 0 deletions packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,51 @@ describe("OpenCode2Server error details", () => {
);
});

describe("OpenCode2Server external server", () => {
it.effect("reverifies after forgetting, so a replaced service's version is seen", () =>
Effect.gen(function* () {
let version = "2.2.4";
const layerService = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify({ version, pid: 1, urls: [], paths: { tmp: "/tmp" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
),
),
);
const versions = yield* Effect.gen(function* () {
const server = yield* OpenCode2Server.make({
binaryPath: "opencode",
serverUrl: "http://127.0.0.1:49374",
serverPassword: "secret",
directory: "/project",
environment: {},
});
const read = server.withConnection((connection) => Effect.succeed(connection.version));
const before = yield* read;
version = "2.2.5";
const cached = yield* read;
yield* server.forget;
return [before, cached, yield* read];
}).pipe(
Effect.provide(
Layer.mergeAll(
OpenCode2Client.layer.pipe(Layer.provide(layerService)),
OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
),
);
assert.deepStrictEqual(versions, ["2.2.4", "2.2.4", "2.2.5"]);
}),
);
});

describe("OpenCode2Server passwords", () => {
it("hands the spawned server only the T3 password", () => {
const password = Redacted.make("t3-generated");
Expand Down
6 changes: 6 additions & 0 deletions packages/provider-opencode/src/server/v2/OpenCode2Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export class OpenCode2Server extends Context.Service<
readonly withConnection: <A, E, R>(
use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
/** Drops a configured server's verified connection, so the next use reads its version again. */
readonly forget: Effect.Effect<void>;
}
>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {}

Expand Down Expand Up @@ -144,6 +146,9 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
Effect.flatMap(use),
);
}),
forget: Effect.sync(() => {
latest = undefined;
}),
});
}

Expand All @@ -162,5 +167,6 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
: Effect.die(new Error("OpenCode 2 server was lent before verification.")),
);
}),
forget: Effect.void,
});
});
96 changes: 96 additions & 0 deletions packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { expect, vi } from "vite-plus/test";

import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
import { replaceOutdatedService } from "./OpenCode2Service.ts";

const service = vi.hoisted(() => ({ discover: vi.fn(), stop: vi.fn(), ensure: vi.fn() }));
vi.mock("@opencode/client/effect/service", () => ({ Service: service }));

const runtimeWithBinaryVersion = (stdout: string) =>
({
runOpenCodeCommand: () => Effect.succeed({ stdout, stderr: "", code: 0 }),
}) as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"];

it.layer(NodeServices.layer)("replaceOutdatedService", (it) => {
it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
"replaces only this install's 2.x service, keeping its configured env",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fileSystem.makeTempDirectoryScoped();
const binDirectory = path.join(home, "bin");
yield* fileSystem.makeDirectory(binDirectory);
yield* fileSystem.writeFileString(path.join(binDirectory, "opencode"), "");
yield* fileSystem.chmod(path.join(binDirectory, "opencode"), 0o755);
const environment = {
HOME: home,
PATH: binDirectory,
OPENCODE_CONFIG_DIR: home,
ELECTRON_RUN_AS_NODE: "1",
};
yield* fileSystem.writeFileString(
path.join(home, "service.json"),
JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }),
);
const file = path.join(home, ".local", "state", "opencode", "service.json");
const run = (binary: string, serverUrl = "http://127.0.0.1:49374") =>
replaceOutdatedService({
binaryPath: "opencode",
serverUrl,
environment,
}).pipe(
Effect.provideService(
OpenCodeRuntime.OpenCodeRuntime,
runtimeWithBinaryVersion(binary),
),
);
service.stop.mockReturnValue(Effect.void);
service.ensure.mockReturnValue(Effect.void);
const outdated = (options: { readonly version?: string }) =>
Effect.succeed(
options.version === undefined ? { url: "http://0.0.0.0:49374" } : undefined,
);

service.discover.mockImplementation(outdated);
yield* run("opencode v1.18.32");
expect(service.discover).not.toHaveBeenCalled();

for (const elsewhere of [
"http://192.168.1.5:49374",
"http://127.0.0.1:49999",
"http://[::1]:49374",
"http://localhost:49374",
]) {
yield* run("opencode v2.2.5", elsewhere);
}
expect(service.discover).toHaveBeenCalledWith({ file });
service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0" }));
yield* run("opencode v2.2.5", "https://127.0.0.1");
expect(service.ensure).not.toHaveBeenCalled();

service.discover.mockReturnValue(Effect.undefined);
yield* run("opencode v2.2.5");
expect(service.ensure).not.toHaveBeenCalled();

service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" }));
yield* run("opencode v2.2.5");
expect(service.stop).not.toHaveBeenCalled();

service.discover.mockImplementation(outdated);
yield* run("opencode v2.2.5");
expect(service.stop).toHaveBeenCalledWith({ file, pty: "handoff" });
expect(service.ensure).toHaveBeenCalledWith({
file,
command: [path.join(binDirectory, "opencode"), "serve", "--service"],
env: { ...environment, PATH: "/nowhere", ELECTRON_RUN_AS_NODE: undefined },
});
}).pipe(Effect.scoped),
);
});
Loading
Loading