This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 546d1a36 [hotfix][java][python] Release skill repositories on any load
failure and remove dead code (#951)
546d1a36 is described below
commit 546d1a36b7ceb546f22ffb42a793547b279a1306
Author: Weiqing Yang <[email protected]>
AuthorDate: Tue Aug 4 02:29:57 2026 -0700
[hotfix][java][python] Release skill repositories on any load failure and
remove dead code (#951)
---
python/flink_agents/runtime/skill/skill_manager.py | 47 ++++-----
.../flink_agents/runtime/skill/skill_repository.py | 20 ----
.../runtime/skill/tests/test_manager.py | 104 ++++++++++++++++++++
.../flink/agents/runtime/skill/SkillManager.java | 67 ++++++-------
.../agents/runtime/skill/SkillManagerTest.java | 107 ++++++++++++++++++---
5 files changed, 241 insertions(+), 104 deletions(-)
diff --git a/python/flink_agents/runtime/skill/skill_manager.py
b/python/flink_agents/runtime/skill/skill_manager.py
index c5ccf592..99f6e87a 100644
--- a/python/flink_agents/runtime/skill/skill_manager.py
+++ b/python/flink_agents/runtime/skill/skill_manager.py
@@ -137,37 +137,24 @@ class SkillManager:
repo = self._repos.get(skill_name)
return None if repo is None else repo.get_skill_dir(skill_name)
- def resolve_resource_path(self, skill_name: str, resource_path: str) ->
Path | None:
- """Resolve a skill resource's relative path to an absolute filesystem
path.
-
- Returns None if the skill's repository doesn't support path resolution.
- """
- repo = self._repos.get(skill_name)
- if repo is None:
- return None
- dir_path = repo.get_skill_dir(skill_name)
- if dir_path is None:
- return None
- resolved = dir_path / resource_path
- return resolved if resolved.is_file() else None
-
def _load_skills(self) -> None:
- for spec in self._config.sources:
- try:
- handler = skill_source_registry.get(spec.scheme)
- repo = handler.open(spec.params)
- self._opened_repos.append(repo)
- except (OSError, ValueError) as e:
- # Release repos opened by earlier iterations — the caller never
- # receives a SkillManager reference to clean them up via
close()
- # itself, so without this their temp dirs / atexit handlers
leak
- # until interpreter exit.
- self.close()
- msg = (
- f"Failed to load skills from {spec.scheme}:{spec.params}"
- )
- raise RuntimeError(msg) from e
- self._register_repo(repo, _origin_of(spec))
+ try:
+ for spec in self._config.sources:
+ try:
+ handler = skill_source_registry.get(spec.scheme)
+ repo = handler.open(spec.params)
+ self._opened_repos.append(repo)
+ except (OSError, ValueError) as e:
+ msg = f"Failed to load skills from
{spec.scheme}:{spec.params}"
+ raise RuntimeError(msg) from e
+ self._register_repo(repo, _origin_of(spec))
+ except BaseException:
+ # Release every repo opened so far — the caller never receives a
+ # SkillManager reference to clean them up via close() itself, so
+ # without this their temp dirs / atexit handlers leak until
+ # interpreter exit.
+ self.close()
+ raise
def _register_repo(self, repo: "SkillRepository", origin: SkillOrigin) ->
None:
for skill in repo.get_skills():
diff --git a/python/flink_agents/runtime/skill/skill_repository.py
b/python/flink_agents/runtime/skill/skill_repository.py
index 5cca1f8b..1b76afe6 100644
--- a/python/flink_agents/runtime/skill/skill_repository.py
+++ b/python/flink_agents/runtime/skill/skill_repository.py
@@ -16,32 +16,12 @@
# limitations under the License.
#################################################################################
from abc import ABC, abstractmethod
-from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List
from flink_agents.runtime.skill.agent_skill import AgentSkill
-@dataclass
-class SkillRepositoryInfo:
- """Information about a skill repository.
-
- Attributes:
- ----------
- repo_type : str
- The type of repository (e.g., "filesystem", "classpath", "url").
- location : str
- The location of the repository (e.g., path, URL).
- writeable : bool
- Whether the repository supports write operations.
- """
-
- repo_type: str
- location: str
- writeable: bool
-
-
class SkillRepository(ABC):
"""Source of skills, loaded from filesystem / classpath / URL / package.
diff --git a/python/flink_agents/runtime/skill/tests/test_manager.py
b/python/flink_agents/runtime/skill/tests/test_manager.py
index 4944f846..5d4c062c 100644
--- a/python/flink_agents/runtime/skill/tests/test_manager.py
+++ b/python/flink_agents/runtime/skill/tests/test_manager.py
@@ -357,3 +357,107 @@ class TestSkillManagerMixedSources:
assert closed == ["skill-1"], (
"the repo opened before the partial-load failure must be closed"
)
+
+ def test_load_failure_outside_wrapped_types_closes_repos_and_propagates(
+ self,
+ ) -> None:
+ # Contract: a source failure that is neither OSError nor ValueError
still
+ # closes the repos opened before it, and reaches the caller unwrapped.
+ from typing import Dict, List
+
+ from flink_agents.runtime.skill import skill_source_registry
+ from flink_agents.runtime.skill.agent_skill import AgentSkill
+ from flink_agents.runtime.skill.skill_repository import SkillRepository
+
+ closed: List[str] = []
+
+ class FakeRepo(SkillRepository):
+ def get_skill(self, name: str) -> AgentSkill | None:
+ return self.get_skills()[0] if name == "skill-1" else None
+
+ def get_skills(self) -> List[AgentSkill]:
+ return [AgentSkill(name="skill-1", description="dummy",
content="body")]
+
+ def get_resources(self, name: str) -> Dict[str, str]:
+ return {}
+
+ def close(self) -> None:
+ closed.append("skill-1")
+
+ counter = {"n": 0}
+
+ def opener(params) -> SkillRepository:
+ counter["n"] += 1
+ if counter["n"] == 2:
+ msg = "corrupt archive"
+ raise zipfile.BadZipFile(msg)
+ return FakeRepo()
+
+ skill_source_registry.register("test-badzip-close", opener)
+
+ config = Skills(
+ sources=[
+ SkillSourceSpec(scheme="test-badzip-close", params={}),
+ SkillSourceSpec(scheme="test-badzip-close", params={}),
+ ]
+ )
+
+ with pytest.raises(zipfile.BadZipFile):
+ SkillManager(config)
+ assert closed == ["skill-1"], (
+ "a load failure outside (OSError, ValueError) must still close the
"
+ "repos opened before it"
+ )
+
+ def test_registration_failure_closes_earlier_repo_and_propagates(self) ->
None:
+ # Contract: a failure raised while registering a repo — after its
open()
+ # already succeeded — still closes the repo from an earlier source.
+ from typing import Dict, List
+
+ from flink_agents.runtime.skill import skill_source_registry
+ from flink_agents.runtime.skill.agent_skill import AgentSkill
+ from flink_agents.runtime.skill.skill_repository import SkillRepository
+
+ closed: List[str] = []
+
+ class FakeRepo(SkillRepository):
+ def __init__(self, tag: str, *, boom: bool) -> None:
+ self._tag = tag
+ self._boom = boom
+
+ def get_skill(self, name: str) -> AgentSkill | None:
+ return None
+
+ def get_skills(self) -> List[AgentSkill]:
+ if self._boom:
+ msg = "exploding during registration"
+ raise KeyError(msg)
+ return [AgentSkill(name=self._tag, description="d",
content="b")]
+
+ def get_resources(self, name: str) -> Dict[str, str]:
+ return {}
+
+ def close(self) -> None:
+ closed.append(self._tag)
+
+ counter = {"n": 0}
+
+ def opener(params) -> SkillRepository:
+ counter["n"] += 1
+ return FakeRepo(f"skill-{counter['n']}", boom=counter["n"] == 2)
+
+ skill_source_registry.register("test-register-boom", opener)
+
+ config = Skills(
+ sources=[
+ SkillSourceSpec(scheme="test-register-boom", params={}),
+ SkillSourceSpec(scheme="test-register-boom", params={}),
+ ]
+ )
+
+ with pytest.raises(KeyError):
+ SkillManager(config)
+ assert closed == ["skill-1", "skill-2"], (
+ "a registration failure must close every repo opened so far — the "
+ "earlier source's repo and the one whose registration failed"
+ )
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
index 4c0eae6c..9111edf6 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java
@@ -26,7 +26,6 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.io.IOException;
-import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
@@ -159,48 +158,38 @@ public class SkillManager implements AutoCloseable {
return repo == null ? null : repo.getSkillDir(skillName);
}
- /** Resolve a skill resource's relative path to an absolute path, or
{@code null} if missing. */
- @Nullable
- public Path resolveResourcePath(String skillName, String resourcePath) {
- SkillRepository repo = repos.get(skillName);
- if (repo == null) {
- return null;
- }
- Path dir = repo.getSkillDir(skillName);
- if (dir == null) {
- return null;
- }
- Path resolved = dir.resolve(resourcePath);
- return Files.isRegularFile(resolved) ? resolved : null;
- }
-
private void loadAll() {
- for (SkillSourceSpec spec : config.getSources()) {
- try {
- SkillRepository repo =
- SkillSourceRegistry.get(spec.getScheme())
- .open(spec.getParams(), classLoader);
- openedRepos.add(repo);
- registerRepo(repo, originOf(spec));
- } catch (IOException | IllegalArgumentException e) {
- IllegalStateException toThrow =
- new IllegalStateException(
- "Failed to load skills from "
- + spec.getScheme()
- + ":"
- + spec.getParams(),
- e);
- // Release repos registered before this point. The caller
never receives a
- // SkillManager reference (we're throwing from the constructor
path), so
- // without this cleanup their shutdown hooks + temp dirs would
leak until
- // JVM exit.
+ try {
+ for (SkillSourceSpec spec : config.getSources()) {
try {
- closeRepos();
- } catch (Exception cleanupError) {
- toThrow.addSuppressed(cleanupError);
+ SkillRepository repo =
+ SkillSourceRegistry.get(spec.getScheme())
+ .open(spec.getParams(), classLoader);
+ openedRepos.add(repo);
+ registerRepo(repo, originOf(spec));
+ } catch (IOException | IllegalArgumentException e) {
+ throw new IllegalStateException(
+ "Failed to load skills from "
+ + spec.getScheme()
+ + ":"
+ + spec.getParams(),
+ e);
}
- throw toThrow;
}
+ } catch (Throwable t) {
+ // Release every repo opened so far, on any failure path. The
caller never
+ // receives a SkillManager reference (we're throwing from the
constructor
+ // path), so without this cleanup their shutdown hooks + temp dirs
would leak
+ // until JVM exit. The original failure propagates unchanged; a
cleanup
+ // failure rides along as suppressed so neither is lost —
including an Error,
+ // which closeRepos() does not catch per-repo and which would
otherwise
+ // replace the original failure outright.
+ try {
+ closeRepos();
+ } catch (Throwable cleanupError) {
+ t.addSuppressed(cleanupError);
+ }
+ throw t;
}
}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
index dfc05f35..daf8660f 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java
@@ -106,14 +106,6 @@ class SkillManagerTest {
assertTrue(ex.getMessage().contains("github"));
}
- @Test
- void resolveResourcePathLocatesBundledFile() {
- SkillManager manager = new SkillManager(configFromResources());
- Path resolved = manager.resolveResourcePath("nano-banana-pro",
"scripts/generate_image.py");
- assertNotNull(resolved);
- assertTrue(Files.isRegularFile(resolved));
- }
-
private static void zipDir(Path src, Path dstZip) throws IOException {
try (ZipOutputStream zos = new
ZipOutputStream(Files.newOutputStream(dstZip));
Stream<Path> walk = Files.walk(src)) {
@@ -299,23 +291,35 @@ class SkillManagerTest {
/**
* Minimal {@link SkillRepository} for lifecycle tests: each instance owns
one fake skill and
- * records whether {@code close()} was invoked. May be configured to throw
a {@link
- * RuntimeException} on close ({@link SkillRepository#close()} declares no
checked exceptions;
- * {@link SkillManager#close()} catches {@code Exception} so this still
exercises the cascade
- * logic).
+ * records whether {@code close()} was invoked. May be configured to fail
on close, exercising
+ * the cascade logic in {@link SkillManager#close()}, and/or to fail from
{@link #getSkills()},
+ * which fails the repo's registration after its {@code open()} has
already succeeded.
+ *
+ * <p>{@link SkillRepository} declares no checked exceptions, so a
configured failure is either
+ * a {@link RuntimeException} or an {@link Error}. Both kinds are needed:
they take different
+ * paths through the handlers in {@link SkillManager}.
*/
private static final class FakeRepo implements SkillRepository {
private final AgentSkill skill;
final AtomicBoolean closed = new AtomicBoolean();
- @javax.annotation.Nullable private final RuntimeException
closeException;
+ @javax.annotation.Nullable private final Throwable closeException;
+ @javax.annotation.Nullable private final Throwable getSkillsException;
FakeRepo(String skillName) {
this(skillName, null);
}
- FakeRepo(String skillName, @javax.annotation.Nullable RuntimeException
closeException) {
+ FakeRepo(String skillName, @javax.annotation.Nullable Throwable
closeException) {
+ this(skillName, closeException, null);
+ }
+
+ FakeRepo(
+ String skillName,
+ @javax.annotation.Nullable Throwable closeException,
+ @javax.annotation.Nullable Throwable getSkillsException) {
this.skill = new AgentSkill(skillName, "fake", "body", null, null,
null);
this.closeException = closeException;
+ this.getSkillsException = getSkillsException;
}
@Override
@@ -325,6 +329,9 @@ class SkillManagerTest {
@Override
public List<AgentSkill> getSkills() {
+ if (getSkillsException != null) {
+ throwUnchecked(getSkillsException);
+ }
return List.of(skill);
}
@@ -337,9 +344,25 @@ class SkillManagerTest {
public void close() {
closed.set(true);
if (closeException != null) {
- throw closeException;
+ throwUnchecked(closeException);
}
}
+
+ /**
+ * Throw a configured failure. Declaring the fields as {@link
Throwable} lets one field
+ * carry either kind of unchecked failure; the interface permits
nothing else, so a checked
+ * exception is a test-setup mistake and fails loudly rather than
being smuggled past the
+ * compiler.
+ */
+ private static void throwUnchecked(Throwable failure) {
+ if (failure instanceof Error) {
+ throw (Error) failure;
+ }
+ if (failure instanceof RuntimeException) {
+ throw (RuntimeException) failure;
+ }
+ throw new AssertionError("FakeRepo failures must be unchecked",
failure);
+ }
}
@Test
@@ -394,6 +417,60 @@ class SkillManagerTest {
assertSame(cleanupBoom, ex.getSuppressed()[0]);
}
+ @Test
+ void registrationFailureOfUnwrappedTypeClosesReposAndPropagates() {
+ // A failure whose type is neither IOException nor
IllegalArgumentException reaches the
+ // caller unchanged rather than being wrapped, so only a guard
spanning every failure path
+ // can release the repos. Two repos are owned by the time registration
fails: the earlier
+ // source's, and the one whose registration failed (it is recorded
before registration
+ // runs). Both must be released, and a failure during that release
must ride along as
+ // suppressed instead of replacing the original.
+ IllegalStateException registrationBoom = new
IllegalStateException("registration-boom");
+ RuntimeException cleanupBoom = new RuntimeException("cleanup-boom");
+ FakeRepo first = new FakeRepo("alpha", cleanupBoom);
+ FakeRepo failing = new FakeRepo("beta", null, registrationBoom);
+ SkillSourceRegistry.register("test-register-boom-ok", (params, cl) ->
first);
+ SkillSourceRegistry.register("test-register-boom-fail", (params, cl)
-> failing);
+
+ Skills config =
+ new Skills(
+ List.of(
+ new SkillSourceSpec("test-register-boom-ok",
Map.of()),
+ new SkillSourceSpec("test-register-boom-fail",
Map.of())));
+
+ IllegalStateException ex =
+ assertThrows(IllegalStateException.class, () -> new
SkillManager(config));
+ // Identity, not just type: IllegalStateException is also what the
wrapping catch builds.
+ assertSame(registrationBoom, ex);
+ assertTrue(first.closed.get(), "repo opened before the failure must be
closed");
+ assertTrue(failing.closed.get(), "repo whose registration failed must
be closed");
+ assertEquals(1, ex.getSuppressed().length);
+ assertSame(cleanupBoom, ex.getSuppressed()[0]);
+ }
+
+ @Test
+ void errorDuringRegistrationStillClosesRepoAndSuppressesCloseError() {
+ // An Error is not an Exception, so it survives a load failure only if
both the guard around
+ // the source loop and the guard around that guard's cleanup accept
Throwable. This repo
+ // fails its registration with one Error and then its close() with
another: a load guard
+ // narrowed to Exception would skip the cleanup entirely, and a
cleanup guard narrowed to
+ // Exception would let the close() Error escape and replace the
registration failure.
+ // One source keeps the assertions deterministic — closeRepos()
catches only Exception per
+ // repo, so an Error from any repo's close() ends the iteration over
the remaining ones.
+ Error registrationBoom = new Error("registration-error");
+ Error closeBoom = new Error("close-error");
+ FakeRepo repo = new FakeRepo("alpha", closeBoom, registrationBoom);
+ SkillSourceRegistry.register("test-error-fail", (params, cl) -> repo);
+
+ Skills config = new Skills(List.of(new
SkillSourceSpec("test-error-fail", Map.of())));
+
+ Error ex = assertThrows(Error.class, () -> new SkillManager(config));
+ assertSame(registrationBoom, ex);
+ assertTrue(repo.closed.get(), "the repo owned when registration failed
must be closed");
+ assertEquals(1, ex.getSuppressed().length);
+ assertSame(closeBoom, ex.getSuppressed()[0]);
+ }
+
@Test
void closeAttemptsEveryRepoAndRethrowsFirstFailure() throws Exception {
// Three repos: middle one throws on close. SkillManager.close() must
(a) attempt all