Skip to content

Commit b68aedf

Browse files
t3dotggmkantenkomaria-rcksclaude
authored
fix(opencode): updating restarts the background service on the new version (#34606)
Continues #31152. Co-authored-by: Miley <[email protected]> Co-authored-by: maria-rcks <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-Authored-By: Claude Fable 5.2 <[email protected]>
1 parent dc032d7 commit b68aedf

10 files changed

Lines changed: 374 additions & 6 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export const replayServer = (
257257
borrowers.current -= 1;
258258
}),
259259
),
260+
forget: Effect.void,
260261
});
261262
});
262263

‎apps/server/src/provider/ProviderRegistry.test.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import type {
7676
import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
7777
import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts";
7878
import * as ProviderRegistry from "./ProviderRegistry.ts";
79+
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
7980
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
8081
import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
8182
vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
@@ -1703,6 +1704,8 @@ it.layer(
17031704
Effect.gen(function* () {
17041705
const forceRefreshCalls = yield* Ref.make(0);
17051706
const manifest = yield* Ref.make(ModelManifest.BUNDLED_MODEL_MANIFEST);
1707+
const updateEvents = yield* Ref.make<ReadonlyArray<string>>([]);
1708+
const hookFails = yield* Ref.make(false);
17061709
const makeInstance = (name: string) =>
17071710
Effect.gen(function* () {
17081711
const driver = ProviderDriverKind.make("codex");
@@ -1722,7 +1725,7 @@ it.layer(
17221725
} as const satisfies ServerProvider;
17231726
const invalidations = yield* Ref.make(0);
17241727
const probes = yield* Ref.make(0);
1725-
const maintenanceReads = yield* Ref.make<ReadonlyArray<boolean>>([]);
1728+
const maintenanceReads = yield* Ref.make<ReadonlyArray<boolean | "afterUpdate">>([]);
17261729
const instance = {
17271730
instanceId,
17281731
driverKind: driver,
@@ -1747,7 +1750,25 @@ it.layer(
17471750
streamChanges: Stream.empty,
17481751
applyUsageLimits: () => Effect.void,
17491752
},
1750-
invalidateCaches: Ref.update(invalidations, (count) => count + 1),
1753+
invalidateCaches: Ref.update(invalidations, (count) => count + 1).pipe(
1754+
Effect.andThen(
1755+
Ref.update(updateEvents, (events) => [...events, `invalidate:${name}`]),
1756+
),
1757+
),
1758+
afterUpdate: Ref.update(maintenanceReads, (reads) => [
1759+
...reads,
1760+
"afterUpdate" as const,
1761+
]).pipe(
1762+
Effect.andThen(
1763+
Ref.update(updateEvents, (events) => [...events, `afterUpdate:${name}`]),
1764+
),
1765+
Effect.andThen(Ref.get(hookFails)),
1766+
Effect.flatMap((fails) =>
1767+
fails
1768+
? Effect.fail(new ProviderDriverError({ driver, instanceId, detail: "hook" }))
1769+
: Effect.void,
1770+
),
1771+
),
17511772
orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
17521773
textGeneration: {} as ProviderInstance["textGeneration"],
17531774
} satisfies ProviderInstance;
@@ -1835,6 +1856,30 @@ it.layer(
18351856
assert.strictEqual(yield* Ref.get(second.probes), probesBefore + 1);
18361857
// A changed manifest also rechecks latest CLI versions.
18371858
assert.strictEqual(yield* readCachedVersion("codex_second-package", "3.0.0"), "3.0.0");
1859+
1860+
yield* first.takeMaintenanceReads;
1861+
yield* Ref.set(updateEvents, []);
1862+
yield* registry.refreshAfterUpdate(first.instance.driverKind, first.instance.instanceId);
1863+
assert.deepStrictEqual((yield* first.takeMaintenanceReads).slice(0, 2), [
1864+
"afterUpdate",
1865+
true,
1866+
]);
1867+
assert.notInclude(yield* second.takeMaintenanceReads, "afterUpdate");
1868+
// Instances sharing the replaced service must not cache its old version.
1869+
assert.strictEqual((yield* Ref.get(updateEvents))[0], "afterUpdate:codex_first");
1870+
1871+
// A failed hook leaves the probe to report what is running.
1872+
yield* Ref.set(hookFails, true);
1873+
const probesBeforeFailedHook = yield* Ref.get(first.probes);
1874+
const refreshed = yield* registry.refreshAfterUpdate(
1875+
first.instance.driverKind,
1876+
first.instance.instanceId,
1877+
);
1878+
assert.strictEqual(yield* Ref.get(first.probes), probesBeforeFailedHook + 1);
1879+
assert.include(
1880+
refreshed.map((provider) => provider.instanceId),
1881+
first.instance.instanceId,
1882+
);
18381883
}).pipe(
18391884
Effect.provide(
18401885
ProviderRegistry.layer.pipe(

‎apps/server/src/provider/ProviderRegistry.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,23 @@ export const layer = Layer.effect(
771771
),
772772
);
773773
});
774+
// Instances can share the replaced service, so none may invalidate before it restarts.
775+
const selectedInstance = instances.find(
776+
(instance) => instance.instanceId === selectedInstanceId,
777+
);
778+
if (selectedInstance?.afterUpdate !== undefined) {
779+
// The probe below reports what is running, so a failed hook needs no status of its own.
780+
yield* selectedInstance.afterUpdate.pipe(
781+
Effect.catchCause((cause) =>
782+
Cause.hasInterruptsOnly(cause)
783+
? Effect.interrupt
784+
: Effect.logError("provider post-update hook failed", {
785+
instanceId: selectedInstance.instanceId,
786+
cause: Cause.pretty(cause),
787+
}),
788+
),
789+
);
790+
}
774791
// Accounts can share an installation, but discovery must use each
775792
// instance's own executable, environment, and credentials.
776793
yield* Effect.forEach(

‎docs/user/providers-opencode.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,18 @@ does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version ch
4242
fail, check the URL, credentials, and OpenCode version, then refresh provider status.
4343

4444
When **Server URL** points to a locally running server (such as `127.0.0.1`),
45-
the update button upgrades its installed CLI without terminating the running server.
46-
The server can still report the previous version until you restart it. T3 Code
47-
shows that restart requirement instead of treating the CLI update as a failure.
48-
Restart the server when active sessions can safely be interrupted.
45+
the update button upgrades its installed CLI. If that server is this machine's
46+
OpenCode background service, T3 Code then restarts it on the new version. The URL
47+
must use the service's own protocol, host, and port. For a service listening on
48+
`0.0.0.0` use `127.0.0.1`, and for `[::]` use `[::1]`.
49+
50+
Restarting interrupts running turns, and the service then runs with T3 Code's
51+
environment. Set any variables it needs with `opencode service set env <name> <value>`.
52+
53+
T3 Code does not restart any other server, including the background service
54+
reached through `localhost`. That server can still report the previous version
55+
until you restart it. For a loopback URL, T3 Code shows that restart requirement
56+
instead of treating the CLI update as a failure.
4957

5058
After a lost connection, send another prompt to reconnect to the same OpenCode
5159
session.

‎packages/provider-core/src/server/driver.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface ProviderInstance {
5050
) => Effect.Effect<ProviderWorkspaceSnapshot, ProviderDriverError>;
5151
readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
5252
readonly invalidateCaches?: Effect.Effect<void>;
53+
/** Moves a long-lived service onto a newly updated binary before its version is read back. */
54+
readonly afterUpdate?: Effect.Effect<void, ProviderDriverError>;
5355
readonly consumeResetCredit?: (
5456
creditId: string | undefined,
5557
) => Effect.Effect<ProviderConsumeResetCreditOutcome, ProviderDriverError>;

‎packages/provider-opencode/src/server/driver.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { makeOpenCodeTextGeneration } from "./textGeneration.ts";
2222
import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
2323
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
2424
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
25+
import { AgentScope } from "@t3tools/shared/AgentScope";
2526
import * as HostProcess from "@t3tools/shared/HostProcess";
2627
import { compareSemverVersions } from "@t3tools/shared/semver";
2728
import * as OpenCodeAdapterV2 from "./adapter.ts";
@@ -56,6 +57,7 @@ import {
5657
import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
5758
import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
5859
import * as OpenCode2Server from "./v2/OpenCode2Server.ts";
60+
import { replaceOutdatedService } from "./v2/OpenCode2Service.ts";
5961
import {
6062
defaultProviderContinuationIdentity,
6163
type ProviderDriver,
@@ -267,6 +269,7 @@ export const OpenCodeDriver: ProviderDriver<
267269
const fileSystem = yield* FileSystem.FileSystem;
268270
const pathService = yield* Path.Path;
269271
const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
272+
const agentScope = yield* AgentScope;
270273
const httpClient = yield* HttpClient.HttpClient;
271274
const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
272275
const digest = yield* Digest.Digest;
@@ -577,6 +580,31 @@ export const OpenCodeDriver: ProviderDriver<
577580
),
578581
);
579582

583+
const serverUrl = effectiveConfig.serverUrl.trim();
584+
const afterUpdate =
585+
serverUrl.length > 0
586+
? replaceOutdatedService({
587+
binaryPath: effectiveConfig.binaryPath,
588+
serverUrl,
589+
environment: processEnv,
590+
}).pipe(
591+
Effect.ensuring(openCode2Server.forget),
592+
Effect.mapError(
593+
(cause) =>
594+
new ProviderDriverError({
595+
driver: DRIVER_KIND,
596+
instanceId,
597+
detail: "Failed to replace the OpenCode service after an update.",
598+
cause,
599+
}),
600+
),
601+
Effect.provideService(AgentScope, agentScope),
602+
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime),
603+
Effect.provideService(FileSystem.FileSystem, fileSystem),
604+
Effect.provideService(Path.Path, pathService),
605+
)
606+
: Effect.void;
607+
580608
const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
581609
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>({
582610
resolveMaintenance,
@@ -709,6 +737,8 @@ export const OpenCodeDriver: ProviderDriver<
709737
}),
710738
),
711739
),
740+
invalidateCaches: openCode2Server.forget,
741+
afterUpdate,
712742
orchestrationAdapter,
713743
textGeneration,
714744
} satisfies ProviderInstance;

‎packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,51 @@ describe("OpenCode2Server error details", () => {
149149
);
150150
});
151151

152+
describe("OpenCode2Server external server", () => {
153+
it.effect("reverifies after forgetting, so a replaced service's version is seen", () =>
154+
Effect.gen(function* () {
155+
let version = "2.2.4";
156+
const layerService = Layer.succeed(
157+
HttpClient.HttpClient,
158+
HttpClient.make((request) =>
159+
Effect.succeed(
160+
HttpClientResponse.fromWeb(
161+
request,
162+
new Response(JSON.stringify({ version, pid: 1, urls: [], paths: { tmp: "/tmp" } }), {
163+
status: 200,
164+
headers: { "content-type": "application/json" },
165+
}),
166+
),
167+
),
168+
),
169+
);
170+
const versions = yield* Effect.gen(function* () {
171+
const server = yield* OpenCode2Server.make({
172+
binaryPath: "opencode",
173+
serverUrl: "http://127.0.0.1:49374",
174+
serverPassword: "secret",
175+
directory: "/project",
176+
environment: {},
177+
});
178+
const read = server.withConnection((connection) => Effect.succeed(connection.version));
179+
const before = yield* read;
180+
version = "2.2.5";
181+
const cached = yield* read;
182+
yield* server.forget;
183+
return [before, cached, yield* read];
184+
}).pipe(
185+
Effect.provide(
186+
Layer.mergeAll(
187+
OpenCode2Client.layer.pipe(Layer.provide(layerService)),
188+
OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
189+
).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
190+
),
191+
);
192+
assert.deepStrictEqual(versions, ["2.2.4", "2.2.4", "2.2.5"]);
193+
}),
194+
);
195+
});
196+
152197
describe("OpenCode2Server passwords", () => {
153198
it("hands the spawned server only the T3 password", () => {
154199
const password = Redacted.make("t3-generated");

‎packages/provider-opencode/src/server/v2/OpenCode2Server.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export class OpenCode2Server extends Context.Service<
2626
readonly withConnection: <A, E, R>(
2727
use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
2828
) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
29+
/** Drops a configured server's verified connection, so the next use reads its version again. */
30+
readonly forget: Effect.Effect<void>;
2931
}
3032
>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {}
3133

@@ -144,6 +146,9 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
144146
Effect.flatMap(use),
145147
);
146148
}),
149+
forget: Effect.sync(() => {
150+
latest = undefined;
151+
}),
147152
});
148153
}
149154

@@ -162,5 +167,6 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
162167
: Effect.die(new Error("OpenCode 2 server was lent before verification.")),
163168
);
164169
}),
170+
forget: Effect.void,
165171
});
166172
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { it } from "@effect/vitest";
3+
import * as HostProcess from "@t3tools/shared/HostProcess";
4+
import * as Effect from "effect/Effect";
5+
import * as FileSystem from "effect/FileSystem";
6+
import * as Path from "effect/Path";
7+
import { expect, vi } from "vite-plus/test";
8+
9+
import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
10+
import { replaceOutdatedService } from "./OpenCode2Service.ts";
11+
12+
const service = vi.hoisted(() => ({ discover: vi.fn(), stop: vi.fn(), ensure: vi.fn() }));
13+
vi.mock("@opencode/client/effect/service", () => ({ Service: service }));
14+
15+
const runtimeWithBinaryVersion = (stdout: string) =>
16+
({
17+
runOpenCodeCommand: () => Effect.succeed({ stdout, stderr: "", code: 0 }),
18+
}) as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"];
19+
20+
it.layer(NodeServices.layer)("replaceOutdatedService", (it) => {
21+
it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
22+
"replaces only this install's 2.x service, keeping its configured env",
23+
() =>
24+
Effect.gen(function* () {
25+
const fileSystem = yield* FileSystem.FileSystem;
26+
const path = yield* Path.Path;
27+
const home = yield* fileSystem.makeTempDirectoryScoped();
28+
const binDirectory = path.join(home, "bin");
29+
yield* fileSystem.makeDirectory(binDirectory);
30+
yield* fileSystem.writeFileString(path.join(binDirectory, "opencode"), "");
31+
yield* fileSystem.chmod(path.join(binDirectory, "opencode"), 0o755);
32+
const environment = {
33+
HOME: home,
34+
PATH: binDirectory,
35+
OPENCODE_CONFIG_DIR: home,
36+
ELECTRON_RUN_AS_NODE: "1",
37+
};
38+
yield* fileSystem.writeFileString(
39+
path.join(home, "service.json"),
40+
JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }),
41+
);
42+
const file = path.join(home, ".local", "state", "opencode", "service.json");
43+
const run = (binary: string, serverUrl = "http://127.0.0.1:49374") =>
44+
replaceOutdatedService({
45+
binaryPath: "opencode",
46+
serverUrl,
47+
environment,
48+
}).pipe(
49+
Effect.provideService(
50+
OpenCodeRuntime.OpenCodeRuntime,
51+
runtimeWithBinaryVersion(binary),
52+
),
53+
);
54+
service.stop.mockReturnValue(Effect.void);
55+
service.ensure.mockReturnValue(Effect.void);
56+
const outdated = (options: { readonly version?: string }) =>
57+
Effect.succeed(
58+
options.version === undefined ? { url: "http://0.0.0.0:49374" } : undefined,
59+
);
60+
61+
service.discover.mockImplementation(outdated);
62+
yield* run("opencode v1.18.32");
63+
expect(service.discover).not.toHaveBeenCalled();
64+
65+
for (const elsewhere of [
66+
"http://192.168.1.5:49374",
67+
"http://127.0.0.1:49999",
68+
"http://[::1]:49374",
69+
"http://localhost:49374",
70+
]) {
71+
yield* run("opencode v2.2.5", elsewhere);
72+
}
73+
expect(service.discover).toHaveBeenCalledWith({ file });
74+
service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0" }));
75+
yield* run("opencode v2.2.5", "https://127.0.0.1");
76+
expect(service.ensure).not.toHaveBeenCalled();
77+
78+
service.discover.mockReturnValue(Effect.undefined);
79+
yield* run("opencode v2.2.5");
80+
expect(service.ensure).not.toHaveBeenCalled();
81+
82+
service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" }));
83+
yield* run("opencode v2.2.5");
84+
expect(service.stop).not.toHaveBeenCalled();
85+
86+
service.discover.mockImplementation(outdated);
87+
yield* run("opencode v2.2.5");
88+
expect(service.stop).toHaveBeenCalledWith({ file, pty: "handoff" });
89+
expect(service.ensure).toHaveBeenCalledWith({
90+
file,
91+
command: [path.join(binDirectory, "opencode"), "serve", "--service"],
92+
env: { ...environment, PATH: "/nowhere", ELECTRON_RUN_AS_NODE: undefined },
93+
});
94+
}).pipe(Effect.scoped),
95+
);
96+
});

0 commit comments

Comments
 (0)