Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -247,6 +247,7 @@ export const replayServer = (
borrowers.current -= 1;
}),
),
forget: Effect.void,
});
});

Expand Down
28 changes: 26 additions & 2 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,7 @@ 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 makeInstance = (name: string) =>
Effect.gen(function* () {
const driver = ProviderDriverKind.make("codex");
Expand All @@ -1512,7 +1513,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 @@ -1537,7 +1538,19 @@ 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}`]),
),
),
orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
} satisfies ProviderInstance;
Expand Down Expand Up @@ -1625,6 +1638,17 @@ 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");
}).pipe(
Effect.provide(
ProviderRegistry.layer.pipe(
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,10 +740,19 @@ 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) {
yield* selectedInstance.afterUpdate.pipe(
Effect.catchCause((cause) => recoverInstanceFailure(selectedInstance.instanceId, cause)),
);
}
// Accounts can share an installation, but discovery must use each
// instance's own executable, environment, and credentials.
yield* Effect.forEach(
instances,
instances.filter((instance) => !failedInstances.has(instance.instanceId)),
(instance) =>
Effect.gen(function* () {
yield* instance.invalidateCaches ?? Effect.void;
Expand Down
9 changes: 9 additions & 0 deletions docs/user/providers-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ settings. T3 Code uses only that configured password for an external server; it
does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version checks
fail, check the URL, credentials, and OpenCode version, then refresh provider status.

If **Server URL** points at this machine's OpenCode background service, updating
OpenCode from T3 Code restarts that service 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]`. With any other address, including
`localhost`, the service keeps running the old version until you restart it.

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>`.

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 @@ -45,6 +45,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 @@ -17,6 +17,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 @@ -47,6 +48,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 @@ -218,6 +220,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 @@ -480,6 +483,31 @@ export const OpenCodeDriver: ProviderDriver<
),
);

const serverUrl = effectiveConfig.serverUrl.trim();
const afterUpdate =
serverUrl.length > 0
? replaceOutdatedService({
binaryPath: effectiveConfig.binaryPath,
serverUrl,
environment: processEnv,
}).pipe(
Comment on lines +489 to +493

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the cached connection after replacing the service

If this instance has already connected to a service older than 2.2.5, replacing it here leaves the adapter using the old version indefinitely. OpenCode2Server.make caches its first connection (v2/OpenCode2Server.ts:129-145), so both adapter reconnects and newly opened sessions receive the old connection.version. The adapter uses that value to enable native goals (v2/adapter.ts:732), meaning native goal handling remains disabled after a successful upgrade to 2.2.5 even though provider status reports the new version. Refresh the cached connection and ensure reconnecting sessions obtain the replacement service’s version.

Useful? React with 👍 / 👎.

Effect.ensuring(openCode2Server.forget),
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: "Failed to replace the OpenCode service after an update.",
cause,
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
),
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 @@ -593,6 +621,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