Repository navigation
fix(server): provider refresh finds CLIs added to the login shell PATH after startup #34514
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4bd7310
d874fd0
90643b5
d0a2cb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL | |
| import * as ModelManifest from "./ModelManifest.ts"; | ||
| import { applyProviderCompatibility } from "./providerCompatibility.ts"; | ||
| import * as ServerConfig from "../config.ts"; | ||
| import { rereadLoginShellPath } from "../os-jank.ts"; | ||
| import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | ||
| import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; | ||
| import { | ||
|
|
@@ -627,6 +628,21 @@ export const layer = Layer.effect( | |
| ); | ||
| }); | ||
|
|
||
| // PATH is read from the login shell at startup and drivers copy it when built, so a CLI | ||
| // installed later needs a fresh read and a rebuild. Installed instances keep their sessions. | ||
| // The PATH merge is global, so every missing instance is rebuilt, not just a refreshed one. | ||
| const rebuildMissingAfterPathChange = Effect.fn("rebuildMissingAfterPathChange")(function* () { | ||
| const instances = yield* instanceRegistry.listInstances; | ||
| const missing = yield* Effect.filter(instances, (instance) => | ||
| // ACP also reports installed:false for setup states unrelated to executable discovery. | ||
| !instance.enabled || instance.driverKind === "acpRegistry" | ||
| ? Effect.succeed(false) | ||
| : instance.snapshot.getSnapshot.pipe(Effect.map((snapshot) => !snapshot.installed)), | ||
| ); | ||
| if (missing.length === 0 || !(yield* rereadLoginShellPath())) return; | ||
| yield* instanceRegistryMutator.rebuild(missing); | ||
| }); | ||
|
|
||
| type RefreshAllCompletion = Deferred.Deferred<ReadonlyArray<ServerProvider>>; | ||
| const refreshAllInFlightRef = yield* Ref.make<Option.Option<RefreshAllCompletion>>( | ||
| Option.none(), | ||
|
|
@@ -656,7 +672,8 @@ export const layer = Layer.effect( | |
| // Syncing first keeps a refresh right after a rebuild from probing the replaced instance. | ||
| yield* manifestService.current.pipe( | ||
| Effect.flatMap(({ updatedAt }) => | ||
| syncLiveSourcesAndContinue.pipe( | ||
| rebuildMissingAfterPathChange().pipe( | ||
| Effect.andThen(syncLiveSourcesAndContinue), | ||
| Effect.andThen(Ref.get(liveSubsRef)), | ||
| Effect.flatMap((instances) => | ||
| Effect.forEach(instances.values(), (instance) => refreshOneSource(instance), { | ||
|
|
@@ -693,6 +710,7 @@ export const layer = Layer.effect( | |
| const refreshInstance = Effect.fn("refreshInstance")(function* ( | ||
| instanceId: ProviderInstanceId, | ||
| ) { | ||
| yield* rebuildMissingAfterPathChange(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium A fresh workspace refresh can return without the requested commands or skills because 🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| yield* syncLiveSourcesAndContinue; | ||
| const instance = (yield* Ref.get(liveSubsRef)).get(instanceId); | ||
| if (!instance) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,8 @@ const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; | |
| const WINDOWS_SHELL_PROBE_TIMEOUT_MS = 5000; | ||
| // A shell stuck in the kernel survives the kill and holds stdout, so execFile's callback never fires. | ||
| const WINDOWS_SHELL_PROBE_DEADLINE = Duration.millis(WINDOWS_SHELL_PROBE_TIMEOUT_MS + 1000); | ||
| const LOGIN_SHELL_PROBE_TIMEOUT_MS = 5000; | ||
| const LOGIN_SHELL_PROBE_DEADLINE = Duration.millis(LOGIN_SHELL_PROBE_TIMEOUT_MS + 1000); | ||
|
|
||
| type ExecFileSyncLike = ( | ||
| file: string, | ||
|
|
@@ -331,6 +333,67 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( | |
| return environment; | ||
| }; | ||
|
|
||
| const execLoginShellProbe = (shell: string, args: ReadonlyArray<string>) => | ||
| Effect.callback<Option.Option<string>>((resume) => { | ||
| let child: NodeChildProcess.ChildProcess; | ||
| try { | ||
| child = NodeChildProcess.spawn(shell, args, { | ||
| detached: true, | ||
| stdio: ["ignore", "pipe", "ignore"], | ||
| timeout: LOGIN_SHELL_PROBE_TIMEOUT_MS, | ||
| killSignal: "SIGKILL", | ||
| }); | ||
| } catch { | ||
| resume(Effect.succeedNone); | ||
| return; | ||
| } | ||
| let output = ""; | ||
| child.stdout?.setEncoding("utf8"); | ||
| child.stdout?.on("data", (chunk: string) => { | ||
| output += chunk; | ||
| }); | ||
|
Comment on lines
+352
to
+354
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bound the login-shell stdout capture. If a shell profile prints continuously or emits a large dump, this handler keeps appending output until the probe ends. Concurrent provider refreshes can accumulate enough output to exhaust server memory. Stop the probe and return no value after a fixed output limit.
|
||
| child.once("error", () => resume(Effect.succeedNone)); | ||
| child.once("close", (code) => | ||
| resume(Effect.succeed(code === 0 ? Option.some(output) : Option.none())), | ||
| ); | ||
| return Effect.sync(() => { | ||
| child.kill("SIGKILL"); | ||
| child.stdout?.destroy(); | ||
| child.unref(); | ||
| }); | ||
| }); | ||
|
|
||
| export type LoginShellEnvironmentReader = ( | ||
| shell: string, | ||
| names: ReadonlyArray<string>, | ||
| ) => Effect.Effect<Partial<Record<string, string>>>; | ||
|
|
||
| /** `readEnvironmentFromLoginShell` without blocking the event loop, for reads after startup. */ | ||
| const readEnvironmentFromLoginShellAsync: LoginShellEnvironmentReader = Effect.fn( | ||
| "shell.readEnvironmentFromLoginShellAsync", | ||
| )(function* (shell, names) { | ||
| if (names.length === 0) return {}; | ||
| const output = yield* execLoginShellProbe(shell, [ | ||
| "-ilc", | ||
| buildEnvironmentCaptureCommand(shell, names), | ||
| ]).pipe(Effect.timeoutOption(LOGIN_SHELL_PROBE_DEADLINE), Effect.map(Option.flatten)); | ||
| if (Option.isNone(output)) return {}; | ||
|
|
||
| const environment: Partial<Record<string, string>> = {}; | ||
| for (const name of names) { | ||
| const value = extractEnvironmentValue(output.value, name); | ||
| if (value !== undefined) environment[name] = value; | ||
| } | ||
| return environment; | ||
| }); | ||
|
|
||
| export const LoginShellEnvironment = Context.Reference<LoginShellEnvironmentReader>( | ||
| "@t3tools/shared/shell/LoginShellEnvironment", | ||
| { | ||
| defaultValue: () => readEnvironmentFromLoginShellAsync, | ||
| }, | ||
| ); | ||
|
|
||
| export type WindowsShellEnvironmentReader = ( | ||
| names: ReadonlyArray<string>, | ||
| options?: WindowsEnvironmentProbeOptions, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: pingdotgg/t3code
Length of output: 7829
Script executed:
Repository: pingdotgg/t3code
Length of output: 41390
Script executed:
Repository: pingdotgg/t3code
Length of output: 31723
Script executed:
Repository: pingdotgg/t3code
Length of output: 22020
Web query:
effect 4.1.2 effect Semaphore withPermits interruptible waiting for permits effect libraryResult:
Web query:
effect@4.1.2 src Semaphore.ts "withPermits" "takePermits" "uninterruptibleMask" restoreResult:
Script executed:
Repository: pingdotgg/t3code
Length of output: 14899
Web query:
site:cdn.jsdelivr.net/npm/effect@4.1.2/src/Semaphore.ts "withPermits(n: number)" "takePermits" "restore"] ]></capability> <capability name="shell" args="pingdotgg/t3code"><![CDATA[ cat -n packages/provider-core/src/server/instanceEnvironment.ts | sed -n '1,100p' cat -n apps/server/src/provider/ProviderRegistry.ts | sed -n '936,960p'Result:
Script executed:
Repository: pingdotgg/t3code
Length of output: 3002
Script executed:
Repository: pingdotgg/t3code
Length of output: 14095
Script executed:
Repository: pingdotgg/t3code
Length of output: 8466
Keep the PATH change pending until rebuilding completes.
If
refreshInstanceis interrupted while the rebuild waits for the registry semaphore, a missing instance can retain its old PATH. A later refresh sees the merged PATH and skips the rebuild. For example, a Codex instance with environment overrides can remain unavailable until a rebuild or restart.Coordinate the PATH update and rebuild so an interrupted refresh can be retried.