From c73d2b1add495f0d6b40adb49f198a26dd906ed0 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 00:45:48 +0800 Subject: [PATCH 01/13] feat(harness): persist team membership for existing sessions --- .../extensions/jdbc/store/JdbcStore.java | 3 +- .../store/TeamSessionMembershipH2Test.java | 343 ++++++++ .../remote/store/InMemoryStore.java | 2 +- .../remote/store/VersionedBaseStore.java | 44 + .../harness/agent/team/LocalTeamClient.java | 50 ++ .../agent/team/TeamSessionMembership.java | 788 ++++++++++++++++++ .../agent/team/TeamSessionMembershipTest.java | 721 ++++++++++++++++ docs/v2/en/docs/harness/architecture.md | 71 ++ docs/v2/zh/docs/harness/architecture.md | 71 ++ 9 files changed, 2091 insertions(+), 2 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java create mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java create mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java index b79dd48504..58a58b2040 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java @@ -21,6 +21,7 @@ import io.agentscope.extensions.jdbc.dialect.table.StoreDialect; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -44,7 +45,7 @@ * * @author shanhongyu */ -public class JdbcStore implements BaseStore { +public class JdbcStore implements VersionedBaseStore { private static final Logger LOG = LoggerFactory.getLogger(JdbcStore.class); diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java new file mode 100644 index 0000000000..18b94eeb19 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -0,0 +1,343 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.jdbc.store; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.state.State; +import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; +import io.agentscope.extensions.jdbc.state.JdbcAgentStateStore; +import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; +import io.agentscope.harness.agent.team.LocalTeamClient; +import io.agentscope.harness.agent.team.TeamConflictException; +import io.agentscope.harness.agent.team.TeamCreateSpec; +import io.agentscope.harness.agent.team.TeamMemberSpec; +import io.agentscope.harness.agent.team.TeamSessionMembership; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Membership; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.Status; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TeamSessionMembershipH2Test { + @TempDir Path temp; + private final TeamAddress a = new TeamAddress("ns-a", "a"); + private final TeamAddress b = new TeamAddress("ns-b", "b"); + + @Test + void fileDatabaseReopenPreservesMembershipTokensMetadataAndExistingSessionState() + throws Exception { + Fixture initial = open(); + create(initial.client(), a); + MemberSession lead = member(initial, "lead", "leader-session", "leader-agent"); + MemberSession worker = member(initial, "worker", "worker-session", "worker-agent"); + String id = + initial.membership() + .adoptTeam("alice", a, "display", List.of(lead)) + .block() + .teamId(); + Membership binding = initial.membership().bindMember("alice", a, worker).block(); + shutdown(initial.ds()); + + Fixture reopened = open(); + assertEquals(id, reopened.membership().getTeam("alice", a).block().teamId()); + assertEquals(binding, reopened.membership().findMembership(worker.session()).block()); + assertEquals(2, reopened.membership().listMemberships("alice", a).block().size()); + assertEquals( + "worker-session", + reopened.client().listMembers("ns-a", "a").block().stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + reopened.client().completeTeam("ns-a", "a").block(); + assertTrue( + reopened.membership() + .unbindMember("alice", a, worker.session(), binding.token()) + .block()); + Membership rebound = reopened.membership().bindMember("alice", a, worker).block(); + assertNotEquals(binding.token(), rebound.token()); + assertThrows( + TeamConflictException.class, + () -> + reopened.membership() + .unbindMember("alice", a, worker.session(), binding.token()) + .block()); + reopened.membership().unbindMember("alice", a, worker.session(), rebound.token()).block(); + shutdown(reopened.ds()); + + Fixture finalRead = open(); + assertNull(finalRead.membership().findMembership(worker.session()).block()); + assertEquals( + "Completed", finalRead.membership().getTeam("alice", a).block().info().phase()); + assertEquals( + 1, + finalRead + .membership() + .adoptTeam("alice", a, "display", List.of(lead)) + .block() + .memberships() + .size()); + assertEquals( + new SavedState("original"), + finalRead + .states() + .get("alice", "worker-session", "legacy", SavedState.class) + .orElseThrow()); + shutdown(finalRead.ds()); + } + + @Test + void independentJdbcStoresAtomicallyCompeteAndIdenticalRequestsRetainOneToken() + throws Exception { + Fixture first = open(), second = open(); + create(first.client(), a); + create(first.client(), b); + first.membership() + .adoptTeam( + "alice", + a, + "same display", + List.of(member(first, "lead", "one", "leader-agent"))) + .block(); + first.membership() + .adoptTeam( + "alice", + b, + "same display", + List.of(member(first, "lead", "two", "leader-agent"))) + .block(); + MemberSession worker = member(first, "worker", "worker", "worker-agent"); + CyclicBarrier barrier = new CyclicBarrier(2); + AtomicInteger writes = new AtomicInteger(); + TeamSessionMembership left = racing(first, barrier, writes); + TeamSessionMembership right = racing(second, barrier, writes); + CompletableFuture x = attempt(() -> left.bindMember("alice", a, worker).block()); + CompletableFuture y = attempt(() -> right.bindMember("alice", b, worker).block()); + List outcomes = List.of(x.get(10, TimeUnit.SECONDS), y.get(10, TimeUnit.SECONDS)); + assertEquals(1, outcomes.stream().filter(Membership.class::isInstance).count()); + assertEquals(1, outcomes.stream().filter(TeamConflictException.class::isInstance).count()); + Membership winner = second.membership().findMembership(worker.session()).block(); + assertEquals(winner, first.membership().findMembership(worker.session()).block()); + first.membership() + .unbindMember("alice", winner.address(), worker.session(), winner.token()) + .block(); + writes.set(0); + x = attempt(() -> left.bindMember("alice", a, worker).block()); + y = attempt(() -> right.bindMember("alice", a, worker).block()); + Object lx = x.get(10, TimeUnit.SECONDS), ry = y.get(10, TimeUnit.SECONDS); + assertTrue(lx instanceof Membership); + assertEquals(lx, ry); + assertEquals(lx, second.membership().findMembership(worker.session()).block()); + shutdown(first.ds()); + } + + @Test + void pendingAdoptionSurvivesDatabaseCloseAndRetryCommitsWithoutDeletingState() + throws Exception { + Fixture first = open(); + create(first.client(), a); + MemberSession lead = member(first, "lead", "one", "leader-agent"); + DelegatingStore failBetweenWrites = + new DelegatingStore(first.store()) { + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + if (ns.get(0).equals("team-session-membership")) { + throw new IllegalStateException("Injected relation write failure"); + } + return super.putIfVersion(ns, key, value, version); + } + }; + TeamSessionMembership failed = + new LocalTeamClient(failBetweenWrites) + .sessionMembership("app", Map.of("sessions", first.states())); + assertThrows( + IllegalStateException.class, + () -> failed.adoptTeam("alice", a, "a", List.of(lead)).block()); + shutdown(first.ds()); + Fixture reopened = open(); + assertEquals(Status.PENDING, reopened.membership().getTeam("alice", a).block().status()); + assertNull(reopened.membership().findMembership(lead.session()).block()); + assertThrows( + IllegalStateException.class, + () -> reopened.client().listMembers("ns-a", "a").block()); + assertEquals( + Status.ACTIVE, + reopened.membership().adoptTeam("alice", a, "a", List.of(lead)).block().status()); + Membership bound = reopened.membership().findMembership(lead.session()).block(); + assertTrue( + reopened.membership() + .unbindMember("alice", a, lead.session(), bound.token()) + .block()); + assertTrue( + reopened.membership() + .adoptTeam("alice", a, "a", List.of(lead)) + .block() + .memberships() + .isEmpty()); + assertFalse( + reopened.membership() + .unbindMember("alice", a, lead.session(), bound.token()) + .block()); + assertTrue(reopened.states().exists("alice", "one")); + shutdown(reopened.ds()); + } + + private Fixture open() { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:file:" + temp.resolve("teams").toAbsolutePath() + ";DB_CLOSE_DELAY=-1"); + ds.setUser("sa"); + ds.setPassword(""); + AbstractJdbcDialect dialect = AbstractJdbcDialect.from(ds).build(); + JdbcStore store = JdbcStore.builder(ds).dialect(dialect).build(); + JdbcAgentStateStore states = new JdbcAgentStateStore(ds, dialect); + LocalTeamClient client = new LocalTeamClient(store); + return new Fixture( + ds, + store, + states, + client, + client.sessionMembership("app", Map.of("sessions", states))); + } + + private static void shutdown(JdbcDataSource ds) throws Exception { + try (Connection connection = ds.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("SHUTDOWN"); + } + } + + private static void create(LocalTeamClient client, TeamAddress address) { + client.createTeam( + new TeamCreateSpec( + address.teamName(), + address.namespace(), + "objective", + "leader-agent", + "", + List.of(new TeamMemberSpec("worker", "worker-agent", "", "byo")))) + .block(); + } + + private static MemberSession member( + Fixture fixture, String name, String sessionId, String agentRef) { + fixture.states().save("alice", sessionId, "legacy", new SavedState("original")); + return new MemberSession( + name, + "definition-owner", + agentRef, + name.equals("lead") ? Role.LEADER : Role.WORKER, + new SessionKey("sessions", "alice", sessionId)); + } + + private static TeamSessionMembership racing( + Fixture fixture, CyclicBarrier barrier, AtomicInteger writes) { + DelegatingStore controlled = + new DelegatingStore(fixture.store()) { + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + if (ns.get(0).equals("team-session-membership") + && writes.incrementAndGet() <= 2) { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + return super.putIfVersion(ns, key, value, version); + } + }; + return new LocalTeamClient(controlled) + .sessionMembership("app", Map.of("sessions", fixture.states())); + } + + private static CompletableFuture attempt(Callable action) { + return CompletableFuture.supplyAsync( + () -> { + try { + return action.call(); + } catch (Exception error) { + return error; + } + }); + } + + private record Fixture( + JdbcDataSource ds, + JdbcStore store, + JdbcAgentStateStore states, + LocalTeamClient client, + TeamSessionMembership membership) {} + + private static class DelegatingStore implements VersionedBaseStore { + private final JdbcStore delegate; + + DelegatingStore(JdbcStore delegate) { + this.delegate = delegate; + } + + @Override + public StoreItem get(List ns, String key) { + return delegate.get(ns, key); + } + + @Override + public void put(List ns, String key, Map value) { + delegate.put(ns, key, value); + } + + @Override + public List search(List ns, int limit, int offset) { + return delegate.search(ns, limit, offset); + } + + @Override + public void delete(List ns, String key) { + delegate.delete(ns, key); + } + + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + return delegate.putIfVersion(ns, key, value, version); + } + } + + /** Preexisting legacy Session state, kept intact by membership operations. */ + public record SavedState(String value) implements State {} +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java index e1246b87c5..5094d67005 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java @@ -31,7 +31,7 @@ *

Every successful {@link #put} and {@link #putIfVersion} increments the item's * {@link StoreItem#version()} counter, enabling optimistic concurrency control. */ -public class InMemoryStore implements BaseStore { +public class InMemoryStore implements VersionedBaseStore { private final ConcurrentMap store = new ConcurrentHashMap<>(); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java new file mode 100644 index 0000000000..7a1e53428b --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.filesystem.remote.store; + +import java.util.List; +import java.util.Map; + +/** + * Optional capability for stores providing atomic item creation and versioned replacement. + * + *

All clients sharing a backend must observe the same positive item versions. Expected version + * zero creates only an absent item; a positive version replaces only that exact version. A failed + * comparison writes nothing. Successful writes advance the version. Storage failures propagate; + * implementations must not emulate CAS with an unprotected read followed by an unconditional put. + * This capability does not provide transactions across items or conditional deletion. + */ +public interface VersionedBaseStore extends BaseStore { + + /** + * Atomically creates or replaces one item according to the version contract of this capability. + * + * @param namespace hierarchical namespace path + * @param key item key + * @param value replacement value + * @param expectedVersion zero for creation, otherwise the observed positive version + * @return true on commit, false on version mismatch without writing + */ + @Override + boolean putIfVersion( + List namespace, String key, Map value, long expectedVersion); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java index f6eac566af..f08000b70b 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java @@ -15,8 +15,10 @@ */ package io.agentscope.harness.agent.team; +import io.agentscope.core.state.AgentStateStore; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -42,6 +44,20 @@ public LocalTeamClient(BaseStore store) { this.store = Objects.requireNonNull(store, "store"); } + /** + * Explicitly enables persistent associations for existing Sessions on a versioned store. + * + * @param relationDomain stable shared App relationship domain, independent of Team namespace + * @param stateStores stable state store domain IDs mapped to the actual Session stores; + * clients sharing the same physical store must use the same ID + * @return usable adoption, binding, query and safe removal operations + * @throws IllegalArgumentException if the store does not declare atomic version support + */ + public TeamSessionMembership sessionMembership( + String relationDomain, Map stateStores) { + return new TeamSessionMembership(store, relationDomain, stateStores); + } + @Override public Mono> listTasks(String namespace, String teamName) { return Mono.fromCallable(() -> readAllTasks(namespace, teamName)) @@ -462,6 +478,12 @@ public Mono> listMessages(String namespace, String teamName, i public Mono> listMembers(String namespace, String teamName) { return Mono.fromCallable( () -> { + List projected = + TeamSessionMembership.projectMembers( + store, namespace, teamName); + if (projected != null) { + return projected; + } List items = store.search(memberNs(namespace, teamName), 100, 0); List out = new ArrayList<>(); @@ -536,6 +558,34 @@ public Mono createTeam(TeamCreateSpec spec) { public Mono completeTeam(String namespace, String teamName) { return Mono.fromRunnable( () -> { + if (store instanceof VersionedBaseStore) { + // A stale unconditional fallback could erase concurrent adoption. + for (int attempt = 0; attempt < 10; attempt++) { + StoreItem current = + store.get(teamNs(namespace, teamName), "meta"); + if (current != null && current.version() <= 0) { + throw new IllegalStateException( + "Store must return positive item versions"); + } + Map latest = + current == null || current.value() == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(current.value()); + if ("Completed".equals(latest.get("phase"))) { + return; + } + latest.put("phase", "Completed"); + if (store.putIfVersion( + teamNs(namespace, teamName), + "meta", + latest, + current == null ? 0 : current.version())) { + return; + } + } + throw new TeamConflictException( + "completeTeam CAS retry limit exceeded"); + } StoreItem item = store.get(teamNs(namespace, teamName), "meta"); Map meta = item == null || item.value() == null diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java new file mode 100644 index 0000000000..1662e32b43 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -0,0 +1,788 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.team; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.state.AgentStateStore; +import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; +import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Explicit, persistent Team associations for existing Leader and BYO member Sessions. + * + *

Team metadata and declared members remain in LocalTeamClient's existing records. Only + * associations and adoption receipts are stored here, in one CAS item per relationship domain and + * owner. The same physical Session store must have the same domain ID on every client. Independent + * stores must have different IDs. The application is responsible for this mapping and definition + * access authorization; state existence is not authorization. + * + *

Session owner must equal Team owner. Unbinding does not delete state, stop runs, revoke tools or + * unregister legacy notifications. All operations are lazy and run store I/O on boundedElastic. + */ +public final class TeamSessionMembership { + + private static final String MARKER = "sessionMembership"; + private static final int MAX_ATTEMPTS = 10; + private static final ObjectMapper JSON = + new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS); + private final VersionedBaseStore store; + private final String domain; + private final Map stateStores; + + TeamSessionMembership( + BaseStore store, String domain, Map stateStores) { + if (!(store instanceof VersionedBaseStore versioned)) { + throw new IllegalArgumentException("Session membership requires VersionedBaseStore"); + } + this.store = versioned; + this.domain = text(domain, "relationship domain"); + this.stateStores = Map.copyOf(stateStores); + this.stateStores.keySet().forEach(id -> text(id, "state store domain")); + } + + /** Declared responsibility, independent of how a member's resources were obtained. */ + public enum Role { + LEADER, + WORKER + } + + /** This capability only associates resources supplied by the application. */ + public enum Source { + BYO + } + + /** Adoption is active only after the relationship item contains its commit receipt. */ + public enum Status { + PENDING, + ACTIVE + } + + /** + * Existing Team storage address. Null/blank namespaces are not reinterpreted on adoption. + * @param namespace existing namespace, preserved verbatim + * @param teamName existing storage/routing name, not the display name + */ + public record TeamAddress(String namespace, String teamName) { + /** Validates an explicit existing address. */ + public TeamAddress { + text(namespace, "namespace"); + text(teamName, "team name"); + } + } + + /** + * Exact Session slot in a configured state store domain. Null owner means anonymous. + * @param stateStoreDomain stable identifier for the actual state store partition + * @param owner nullable Session owner + * @param sessionId Session identifier within that partition and owner + */ + public record SessionKey(String stateStoreDomain, String owner, String sessionId) { + /** Validates the slot, rejecting reserved anonymous aliases. */ + public SessionKey { + text(stateStoreDomain, "state store domain"); + validOwner(owner); + text(sessionId, "session ID"); + } + } + + /** + * Application-supplied existing Session and its declared member identity. + * @param memberName alias in the existing Team member directory + * @param definitionOwner nullable definition owner, separate from Session owner + * @param agentRef declared Agent reference + * @param role declared Leader or Worker role + * @param session exact existing Session + */ + public record MemberSession( + String memberName, + String definitionOwner, + String agentRef, + Role role, + SessionKey session) { + /** Validates the complete identity. */ + public MemberSession { + text(memberName, "member name"); + validOwner(definitionOwner); + text(agentRef, "agent reference"); + Objects.requireNonNull(role, "role"); + Objects.requireNonNull(session, "session"); + } + } + + /** + * Immutable current association. Token identifies this particular binding, not authorization. + * @param teamOwner nullable Team owner + * @param teamId stable logical Team ID + * @param address legacy Team address + * @param member complete member and Session identity + * @param source resource origin, currently BYO + * @param token persistent expected token for safe removal + */ + public record Membership( + String teamOwner, + String teamId, + TeamAddress address, + MemberSession member, + Source source, + String token) { + /** Validates an association. */ + public Membership { + validOwner(teamOwner); + text(teamId, "team ID"); + Objects.requireNonNull(address, "address"); + Objects.requireNonNull(member, "member"); + Objects.requireNonNull(source, "source"); + text(token, "binding token"); + sameOwner(teamOwner, member.session()); + } + } + + /** + * Team metadata from the original record and current Session associations from one authority. + * @param teamOwner nullable Team owner + * @param teamId stable logical ID, distinct from address and display name + * @param address legacy storage/routing address + * @param displayName display name, allowed to repeat across Teams + * @param info original Team metadata; its phase is not a runtime status + * @param status adoption commit status + * @param memberships currently associated Sessions; empty while PENDING + */ + public record TeamView( + String teamOwner, + String teamId, + TeamAddress address, + String displayName, + TeamInfo info, + Status status, + List memberships) { + /** Makes the returned association list immutable. */ + public TeamView { + memberships = List.copyOf(memberships); + } + } + + /** + * Adopts an existing Team using a complete initial request with exactly one declared Leader. + * + *

The meta marker is written first; a single owner-item CAS commits all initial associations + * and the receipt. Failure between these writes leaves an observable PENDING adoption. Retry the + * same request to finish it. A committed request replay returns current associations, without + * restoring subsequently unbound members. Different adoption requests cannot replace the marker. + * + * @param owner nullable Team owner, also the owner of every supplied Session + * @param address existing Team address + * @param displayName human-readable name + * @param initialMembers existing Leader and optional declared BYO members + * @return current Team view after commit; errors may leave a retryable PENDING marker + */ + public Mono adoptTeam( + String owner, + TeamAddress address, + String displayName, + List initialMembers) { + return io( + () -> { + validOwner(owner); + text(displayName, "display name"); + List initial = canonical(initialMembers); + if (initial.stream().filter(m -> m.role() == Role.LEADER).count() != 1) { + throw new IllegalArgumentException("Adoption requires exactly one Leader"); + } + initial.forEach(m -> sameOwner(owner, m.session())); + Marker marker = null; + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + StoreItem meta = requireMeta(address); + Marker existing = marker(meta); + if (existing != null) { + requireScope(existing, owner); + if (!existing.displayName().equals(displayName) + || !existing.initial().equals(initial)) { + throw new TeamConflictException( + "Team has a different adoption request"); + } + marker = existing; + if (committed(readOwner(owner).state(), marker)) { + return view(address, meta, marker); + } + break; + } + validateInitial(owner, address, initial); + Marker next = + new Marker( + 1, + domain, + owner, + UUID.randomUUID().toString(), + displayName, + UUID.randomUUID().toString(), + initial); + Map value = new LinkedHashMap<>(meta.value()); + value.put(MARKER, encode(next)); + if (store.putIfVersion(teamNs(address), "meta", value, version(meta))) { + marker = next; + break; + } + } + if (marker == null) { + throw contention(); + } + validateInitial(owner, address, initial); + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + OwnerItem current = readOwner(owner); + if (committed(current.state(), marker)) { + return get(owner, address); + } + List next = new ArrayList<>(current.state().memberships()); + for (MemberSession member : initial) { + add( + next, + new Membership( + owner, + marker.teamId(), + address, + member, + Source.BYO, + UUID.randomUUID().toString())); + } + Map receipts = new HashMap<>(current.state().receipts()); + receipts.put(marker.teamId(), marker.registrationToken()); + if (writeOwner(owner, current, receipts, next)) { + return get(owner, address); + } + } + throw contention(); + }); + } + + /** + * Binds an existing declared member to its persisted Session. Identical requests retain token. + * @param owner nullable Team owner + * @param address adopted Team address + * @param member complete declared member and existing Session + * @return committed association, or an error on a conflicting Session, alias or Leader + */ + public Mono bindMember(String owner, TeamAddress address, MemberSession member) { + return io( + () -> { + Marker marker = requireActive(owner, address); + validateMember(owner, address, member); + Membership proposed = + new Membership( + owner, + marker.teamId(), + address, + member, + Source.BYO, + UUID.randomUUID().toString()); + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + OwnerItem current = readOwner(owner); + if (!committed(current.state(), marker)) { + throw corrupt(); + } + List next = new ArrayList<>(current.state().memberships()); + Membership result = add(next, proposed); + if (result != proposed + || writeOwner(owner, current, current.state().receipts(), next)) { + return result; + } + } + throw contention(); + }); + } + + /** + * Queries an adopted Team; PENDING is explicitly reported with no committed members. + * @param owner nullable Team owner + * @param address adopted Team address + * @return current view, or error for an absent, unadopted or differently owned Team + */ + public Mono getTeam(String owner, TeamAddress address) { + return io(() -> get(owner, address)); + } + + /** + * Finds the current association of a precisely identified Session, including across namespaces. + * @param session Session identity in a configured state store domain + * @return empty if unbound; does not require the Session still to exist + */ + public Mono findMembership(SessionKey session) { + return io( + () -> { + requireStateStore(session); + return readOwner(session.owner()).state().memberships().stream() + .filter(m -> m.member().session().equals(session)) + .findFirst() + .orElse(null); + }); + } + + /** + * Lists currently associated Sessions, independent of the old declared member directory. + * @param owner nullable Team owner + * @param address adopted Team address + * @return immutable list; PENDING adoption fails explicitly and can be retried + */ + public Mono> listMemberships(String owner, TeamAddress address) { + return io( + () -> { + TeamView result = get(owner, address); + active(result.status()); + return result.memberships(); + }); + } + + /** + * Removes only the expected current association. Repeated removal of an absent slot is a no-op. + * @param owner nullable Team owner + * @param address adopted Team address + * @param session exact Session to unbind + * @param expectedToken token returned by the binding being removed + * @return true if removed, false if already absent; wrong Team or stale token fails + */ + public Mono unbindMember( + String owner, TeamAddress address, SessionKey session, String expectedToken) { + return io( + () -> { + sameOwner(owner, session); + text(expectedToken, "expected binding token"); + Marker marker = requireActive(owner, address); + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + OwnerItem current = readOwner(owner); + List next = new ArrayList<>(current.state().memberships()); + Membership bound = + next.stream() + .filter(m -> m.member().session().equals(session)) + .findFirst() + .orElse(null); + if (bound == null) { + return false; + } + if (!bound.teamId().equals(marker.teamId()) + || !bound.address().equals(address) + || !bound.token().equals(expectedToken)) { + throw new TeamConflictException("Team or binding token does not match"); + } + next.remove(bound); + if (writeOwner(owner, current, current.state().receipts(), next)) { + return true; + } + } + throw contention(); + }); + } + + private TeamView get(String owner, TeamAddress address) { + StoreItem meta = requireMeta(address); + Marker marker = marker(meta); + if (marker == null) { + throw new IllegalArgumentException("Team has not been adopted"); + } + requireScope(marker, owner); + return view(address, meta, marker); + } + + private TeamView view(TeamAddress address, StoreItem meta, Marker marker) { + OwnerState state = readOwner(marker.owner()).state(); + boolean committed = committed(state, marker); + TeamInfo info = + new TeamInfo( + address.teamName(), + address.namespace(), + string(meta.value().get("objective")), + string(meta.value().get("phase")), + string(meta.value().get("leadRef"))); + return new TeamView( + marker.owner(), + marker.teamId(), + address, + marker.displayName(), + info, + committed ? Status.ACTIVE : Status.PENDING, + committed ? members(state, marker, address) : List.of()); + } + + private Marker requireActive(String owner, TeamAddress address) { + Marker marker = marker(requireMeta(address)); + if (marker == null) { + throw new IllegalArgumentException("Team has not been adopted"); + } + requireScope(marker, owner); + active(committed(readOwner(owner).state(), marker) ? Status.ACTIVE : Status.PENDING); + return marker; + } + + private void requireScope(Marker marker, String owner) { + validOwner(owner); + if (!marker.domain().equals(domain) || !Objects.equals(marker.owner(), owner)) { + throw new TeamConflictException("Team belongs to another owner or relationship domain"); + } + } + + private void validateInitial(String owner, TeamAddress address, List initial) { + Set aliases = new HashSet<>(); + Set sessions = new HashSet<>(); + for (MemberSession member : initial) { + if (!aliases.add(member.memberName()) || !sessions.add(member.session())) { + throw new IllegalArgumentException("Duplicate initial member or Session"); + } + validateMember(owner, address, member); + } + for (TeamMemberInfo declared : declaredMembers(store, address)) { + if (declared.sessionId() != null + && !declared.sessionId().isEmpty() + && initial.stream() + .noneMatch( + m -> + m.memberName().equals(declared.memberName()) + && m.session() + .sessionId() + .equals(declared.sessionId()))) { + throw new IllegalArgumentException( + "Supply complete identities for legacy Session bindings"); + } + } + } + + private void validateMember(String owner, TeamAddress address, MemberSession member) { + sameOwner(owner, member.session()); + StoreItem item = store.get(memberNs(address), member.memberName()); + if (item == null || item.value() == null) { + throw new IllegalArgumentException("Member is not declared in the existing Team"); + } + TeamMemberInfo declared = TeamMemberInfo.fromMap(item.value()); + if (!declared.memberName().equals(member.memberName()) + || !declared.agentRef().equals(member.agentRef()) + || declared.isLead() != (member.role() == Role.LEADER)) { + throw new TeamConflictException("Member definition or declared role does not match"); + } + if (!requireStateStore(member.session()).exists(owner, member.session().sessionId())) { + throw new IllegalArgumentException( + "Session has no persisted state in the configured domain"); + } + } + + private AgentStateStore requireStateStore(SessionKey session) { + AgentStateStore result = stateStores.get(session.stateStoreDomain()); + if (result == null) { + throw new IllegalArgumentException("Unknown state store domain"); + } + return result; + } + + private StoreItem requireMeta(TeamAddress address) { + StoreItem item = store.get(teamNs(address), "meta"); + if (item == null || item.value() == null) { + throw new IllegalArgumentException("Existing Team not found"); + } + version(item); + return item; + } + + private OwnerItem readOwner(String owner) { + return readOwner(store, domain, owner); + } + + private static OwnerItem readOwner(BaseStore store, String domain, String owner) { + StoreItem item = store.get(relationNs(domain), ownerKey(owner)); + if (item == null) { + return new OwnerItem(0, new OwnerState(1, Map.of(), List.of())); + } + OwnerState state = decode(item.value(), OwnerState.class); + if (state.schema() != 1) { + throw corrupt(); + } + Set sessions = new HashSet<>(); + Map> aliases = new HashMap<>(); + Set leaders = new HashSet<>(); + Map addresses = new HashMap<>(); + for (Membership m : state.memberships()) { + TeamAddress previous = addresses.putIfAbsent(m.teamId(), m.address()); + if (!Objects.equals(owner, m.teamOwner()) + || !state.receipts().containsKey(m.teamId()) + || !sessions.add(m.member().session()) + || !aliases.computeIfAbsent(m.teamId(), k -> new HashSet<>()) + .add(m.member().memberName()) + || (m.member().role() == Role.LEADER && !leaders.add(m.teamId())) + || (previous != null && !previous.equals(m.address()))) { + throw corrupt(); + } + } + state.receipts() + .forEach( + (id, token) -> { + text(id, "team ID"); + text(token, "registration token"); + }); + return new OwnerItem(version(item), state); + } + + private boolean writeOwner( + String owner, + OwnerItem current, + Map receipts, + List memberships) { + return store.putIfVersion( + relationNs(domain), + ownerKey(owner), + encode(new OwnerState(1, receipts, memberships)), + current.version()); + } + + private static Membership add(List memberships, Membership proposed) { + for (Membership m : memberships) { + if (m.member().session().equals(proposed.member().session())) { + if (m.teamId().equals(proposed.teamId()) + && m.address().equals(proposed.address()) + && m.member().equals(proposed.member())) { + return m; + } + throw new TeamConflictException("Session is already associated"); + } + if (m.teamId().equals(proposed.teamId()) + && (m.member().memberName().equals(proposed.member().memberName()) + || (m.member().role() == Role.LEADER + && proposed.member().role() == Role.LEADER))) { + throw new TeamConflictException("Member alias or Leader is already associated"); + } + } + memberships.add(proposed); + return proposed; + } + + private static boolean committed(OwnerState state, Marker marker) { + String receipt = state.receipts().get(marker.teamId()); + if (receipt != null && !receipt.equals(marker.registrationToken())) { + throw corrupt(); + } + return receipt != null; + } + + private static List members(OwnerState state, Marker marker, TeamAddress address) { + List result = + state.memberships().stream() + .filter(m -> m.teamId().equals(marker.teamId())) + .toList(); + if (result.stream().anyMatch(m -> !m.address().equals(address))) { + throw corrupt(); + } + return result; + } + + static List projectMembers(BaseStore store, String namespace, String teamName) { + StoreItem meta = store.get(List.of("teams", namespace, teamName), "meta"); + Marker marker = marker(meta); + if (marker == null) { + return null; + } + if (!(store instanceof VersionedBaseStore)) { + throw new IllegalStateException("Adopted Teams require VersionedBaseStore"); + } + TeamAddress address = new TeamAddress(namespace, teamName); + OwnerState state = readOwner(store, marker.domain(), marker.owner()).state(); + active(committed(state, marker) ? Status.ACTIVE : Status.PENDING); + Map sessions = new HashMap<>(); + members(state, marker, address) + .forEach( + m -> + sessions.put( + m.member().memberName(), m.member().session().sessionId())); + return declaredMembers(store, address).stream() + .map( + m -> + new TeamMemberInfo( + m.memberName(), + m.agentRef(), + m.phase(), + sessions.getOrDefault(m.memberName(), ""), + m.deployMode(), + m.isLead())) + .toList(); + } + + private static List declaredMembers(BaseStore store, TeamAddress address) { + List result = new ArrayList<>(); + for (int offset = 0; ; offset += 100) { + List page = store.search(memberNs(address), 100, offset); + for (StoreItem item : page) { + if (item == null || item.value() == null) { + throw corrupt(); + } + result.add(TeamMemberInfo.fromMap(item.value())); + } + if (page.size() < 100) { + return List.copyOf(result); + } + } + } + + private static Marker marker(StoreItem meta) { + if (meta == null || meta.value() == null || !meta.value().containsKey(MARKER)) { + return null; + } + Marker marker = decode(meta.value().get(MARKER), Marker.class); + if (marker.schema() != 1) { + throw corrupt(); + } + return marker; + } + + private record Marker( + int schema, + String domain, + String owner, + String teamId, + String displayName, + String registrationToken, + List initial) { + Marker { + text(domain, "relationship domain"); + validOwner(owner); + text(teamId, "team ID"); + text(displayName, "display name"); + text(registrationToken, "registration token"); + initial = canonical(initial); + if (initial.stream().filter(m -> m.role() == Role.LEADER).count() != 1) { + throw corrupt(); + } + initial.forEach(m -> sameOwner(owner, m.session())); + } + } + + private record OwnerState( + int schema, Map receipts, List memberships) { + OwnerState { + receipts = Map.copyOf(receipts); + memberships = List.copyOf(memberships); + } + } + + private record OwnerItem(long version, OwnerState state) {} + + private static Map encode(Object value) { + return JSON.convertValue(value, new TypeReference<>() {}); + } + + private static T decode(Object value, Class type) { + try { + return Objects.requireNonNull(JSON.convertValue(value, type)); + } catch (RuntimeException error) { + throw new IllegalStateException("Invalid Session membership data", error); + } + } + + private static List canonical(List initial) { + return List.copyOf(initial).stream() + .sorted(Comparator.comparing(MemberSession::memberName)) + .toList(); + } + + private static List teamNs(TeamAddress address) { + return List.of("teams", address.namespace(), address.teamName()); + } + + private static List memberNs(TeamAddress address) { + return List.of("teams", address.namespace(), address.teamName(), "members"); + } + + private static List relationNs(String domain) { + return List.of("team-session-membership", encoded(domain)); + } + + private static String ownerKey(String owner) { + return owner == null ? "anonymous" : "owner-" + encoded(owner); + } + + private static String encoded(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String string(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static String text(String value, String name) { + if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException( + name + " must be nonblank and contain no control characters"); + } + return value; + } + + private static void validOwner(String owner) { + if (owner != null) { + text(owner, "owner"); + if (owner.equals("__anon__")) { + throw new IllegalArgumentException("Reserved anonymous owner"); + } + } + } + + private static void sameOwner(String owner, SessionKey session) { + validOwner(owner); + if (!Objects.equals(owner, session.owner())) { + throw new IllegalArgumentException("Session owner must equal Team owner"); + } + } + + private static long version(StoreItem item) { + if (item.version() <= 0) { + throw new IllegalStateException("Store must return positive item versions"); + } + return item.version(); + } + + private static void active(Status status) { + if (status != Status.ACTIVE) { + throw new IllegalStateException( + "Team adoption is PENDING; retry the same adoptTeam request"); + } + } + + private static IllegalStateException corrupt() { + return new IllegalStateException("Invalid Session membership data"); + } + + private static TeamConflictException contention() { + return new TeamConflictException("Session membership CAS retry limit exceeded"); + } + + private static Mono io(Callable action) { + return Mono.fromCallable(action).subscribeOn(Schedulers.boundedElastic()); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java new file mode 100644 index 0000000000..8c0fd61bdc --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -0,0 +1,721 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.team; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.state.AgentStateStore; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.core.state.JsonFileAgentStateStore; +import io.agentscope.core.state.State; +import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; +import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; +import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Membership; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.Source; +import io.agentscope.harness.agent.team.TeamSessionMembership.Status; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamView; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TeamSessionMembershipTest { + @TempDir Path temp; + private InMemoryStore store; + private InMemoryAgentStateStore states; + private LocalTeamClient client; + private TeamSessionMembership service; + private final TeamAddress a = new TeamAddress("ns-a", "a"); + private final TeamAddress b = new TeamAddress("ns-b", "b"); + + @BeforeEach + void setUp() { + store = new InMemoryStore(); + states = new InMemoryAgentStateStore(); + client = new LocalTeamClient(store); + service = client.sessionMembership("app", Map.of("state", states)); + create(a); + create(b); + } + + @Test + void usableLifecycleKeepsLegacyMetadataStateAndWorkspace() throws Exception { + JsonFileAgentStateStore files = new JsonFileAgentStateStore(temp.resolve("state")); + service = client.sessionMembership("app", Map.of("state", files)); + MemberSession leader = member(files, "alice", "lead", "leader", "leader-agent"); + MemberSession worker = member(files, "alice", "worker", "worker", "worker-agent"); + Path workspace = temp.resolve("workspace.txt"); + Files.writeString(workspace, "existing files"); + TeamView view = service.adoptTeam("alice", a, "research", List.of(leader)).block(); + Membership bound = service.bindMember("alice", a, worker).block(); + assertEquals(Source.BYO, bound.source()); + assertEquals(bound, service.findMembership(worker.session()).block()); + assertEquals(2, service.listMemberships("alice", a).block().size()); + assertEquals("worker", legacy("worker").sessionId()); + assertEquals(bound, service.bindMember("alice", a, worker).block()); + client.completeTeam(a.namespace(), a.teamName()).block(); + assertEquals("Completed", service.getTeam("alice", a).block().info().phase()); + assertTrue(service.unbindMember("alice", a, worker.session(), bound.token()).block()); + assertFalse(service.unbindMember("alice", a, worker.session(), bound.token()).block()); + assertNull(service.findMembership(worker.session()).block()); + assertEquals("", legacy("worker").sessionId()); + assertEquals("worker-agent", legacy("worker").agentRef()); + assertEquals("Working", legacy("worker").phase()); + assertEquals("existing files", Files.readString(workspace)); + assertEquals( + new SavedState("original"), + files.get("alice", "worker", "legacy", SavedState.class).orElseThrow()); + TeamView replay = service.adoptTeam("alice", a, "research", List.of(leader)).block(); + assertEquals(view.teamId(), replay.teamId()); + assertEquals(1, replay.memberships().size()); + Membership newBinding = service.bindMember("alice", a, worker).block(); + assertNotEquals(bound.token(), newBinding.token()); + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, worker.session(), bound.token()).block()); + assertEquals(newBinding, service.findMembership(worker.session()).block()); + } + + @Test + void sameAgentDifferentSessionsCanJoinDifferentTeamsAndSameDisplayName() { + MemberSession la = member(states, "alice", "lead", "one", "leader-agent"); + MemberSession lb = member(states, "alice", "lead", "two", "leader-agent"); + TeamView va = service.adoptTeam("alice", a, "same name", List.of(la)).block(); + TeamView vb = service.adoptTeam("alice", b, "same name", List.of(lb)).block(); + assertNotEquals(va.teamId(), vb.teamId()); + assertEquals(va.displayName(), vb.displayName()); + assertEquals(va.teamId(), service.findMembership(la.session()).block().teamId()); + assertEquals(vb.teamId(), service.findMembership(lb.session()).block().teamId()); + MemberSession wa = member(states, "alice", "worker", "worker-one", "worker-agent"); + service.bindMember("alice", a, wa).block(); + assertThrows(TeamConflictException.class, () -> service.bindMember("alice", b, wa).block()); + assertThrows( + TeamConflictException.class, + () -> + service.bindMember( + "alice", + a, + member( + states, + "alice", + "worker", + "worker-two", + "worker-agent")) + .block()); + assertThrows(TeamConflictException.class, () -> service.bindMember("alice", a, lb).block()); + } + + @Test + void releasingLeaderDoesNotReplayAdoptionOrPreventExplicitReplacement() { + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + TeamView view = service.adoptTeam("alice", a, "a", List.of(lead)).block(); + Membership binding = view.memberships().get(0); + service.unbindMember("alice", a, lead.session(), binding.token()).block(); + assertTrue( + service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().isEmpty()); + MemberSession next = member(states, "alice", "lead", "replacement", "leader-agent"); + assertEquals(next, service.bindMember("alice", a, next).block().member()); + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, next.session(), binding.token()).block()); + } + + @Test + void ownerAndActualStateDomainsDistinguishIdenticalSessionStrings() { + InMemoryAgentStateStore separate = new InMemoryAgentStateStore(); + service = client.sessionMembership("app", Map.of("state", states, "separate", separate)); + MemberSession la = member(states, "alice", "lead", "same", "leader-agent"); + MemberSession lb = + new MemberSession( + "lead", + "definition-owner", + "leader-agent", + Role.LEADER, + new SessionKey("separate", "alice", "same")); + separate.save("alice", "same", "legacy", new SavedState("original")); + service.adoptTeam("alice", a, "a", List.of(la)).block(); + service.adoptTeam("alice", b, "b", List.of(lb)).block(); + assertNotEquals( + service.findMembership(la.session()).block().teamId(), + service.findMembership(lb.session()).block().teamId()); + TeamAddress c = new TeamAddress("ns-a", "c"); + create(c); + MemberSession other = member(states, "bob", "lead", "same", "leader-agent"); + service.adoptTeam("bob", c, "a", List.of(other)).block(); + assertEquals("bob", service.findMembership(other.session()).block().teamOwner()); + assertThrows(TeamConflictException.class, () -> service.getTeam("bob", a).block()); + assertThrows( + TeamConflictException.class, + () -> + client.sessionMembership("other-app", Map.of("state", states)) + .adoptTeam("alice", a, "a", List.of(la)) + .block()); + } + + @Test + void anonymousSessionsAreSupportedAndReservedAliasesRejected() { + MemberSession anonymous = member(states, null, "lead", "same", "leader-agent"); + service.adoptTeam(null, a, "anonymous", List.of(anonymous)).block(); + assertNull(service.findMembership(anonymous.session()).block().teamOwner()); + assertThrows( + IllegalArgumentException.class, () -> new SessionKey("state", "__anon__", "same")); + assertThrows(IllegalArgumentException.class, () -> new SessionKey("state", " ", "same")); + assertThrows(IllegalArgumentException.class, () -> new SessionKey("state", null, " ")); + assertThrows(IllegalArgumentException.class, () -> new TeamAddress("", "a")); + assertThrows(IllegalArgumentException.class, () -> new TeamAddress("ns\0a", "a")); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", b, "b", List.of(anonymous)).block()); + } + + @Test + void requiresRealPersistedSessionsAndMatchingDeclaredDefinitions() { + MemberSession missing = + new MemberSession( + "lead", + "alice", + "leader-agent", + Role.LEADER, + new SessionKey("state", "alice", "missing")); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "a", List.of(missing)).block()); + assertFalse( + store.get(List.of("teams", "ns-a", "a"), "meta") + .value() + .containsKey("sessionMembership")); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + MemberSession wrongRef = member(states, "alice", "worker", "worker", "wrong-agent"); + assertThrows( + TeamConflictException.class, + () -> service.bindMember("alice", a, wrongRef).block()); + MemberSession absent = member(states, "alice", "missing", "worker", "worker-agent"); + assertThrows( + IllegalArgumentException.class, + () -> service.bindMember("alice", a, absent).block()); + assertThrows( + IllegalArgumentException.class, + () -> service.findMembership(new SessionKey("unknown", "alice", "one")).block()); + } + + @Test + void adoptionRequiresExplicitIdentitiesForLegacyNonemptySessionFields() { + store.put( + List.of("teams", "ns-a", "a", "members"), + "worker", + new TeamMemberInfo("worker", "worker-agent", "Working", "old", "byo", false) + .toMap()); + MemberSession lead = member(states, "alice", "lead", "lead", "leader-agent"); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + assertEquals("old", legacy("worker").sessionId()); + MemberSession old = member(states, "alice", "worker", "old", "worker-agent"); + TeamView view = service.adoptTeam("alice", a, "a", List.of(old, lead)).block(); + assertEquals(2, view.memberships().size()); + assertEquals(view, service.adoptTeam("alice", a, "a", List.of(lead, old)).block()); + assertThrows( + TeamConflictException.class, + () -> service.adoptTeam("alice", a, "renamed", List.of(lead, old)).block()); + } + + @Test + void pendingAdoptionAfterFailureIsObservableAndRecoverableByNewClient() { + FaultStore faulty = useFaultStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + faulty.beforeRelation = true; + assertThrows( + IllegalStateException.class, + () -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + assertEquals(Status.PENDING, service.getTeam("alice", a).block().status()); + assertTrue(service.getTeam("alice", a).block().memberships().isEmpty()); + assertNull(service.findMembership(lead.session()).block()); + assertThrows(IllegalStateException.class, () -> client.listMembers("ns-a", "a").block()); + assertThrows( + IllegalStateException.class, () -> service.listMemberships("alice", a).block()); + faulty.beforeRelation = false; + TeamSessionMembership reopened = + new LocalTeamClient(faulty).sessionMembership("app", Map.of("state", states)); + assertEquals( + Status.ACTIVE, reopened.adoptTeam("alice", a, "a", List.of(lead)).block().status()); + assertEquals("one", legacy("lead").sessionId()); + } + + @Test + void failedMetaWriteDoesNotEnableTeamAndCommittedResponseFailureCanBeRetried() { + FaultStore faulty = useFaultStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + faulty.beforeMeta = true; + assertThrows( + IllegalStateException.class, + () -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + assertEquals("", legacy("lead").sessionId()); + faulty.beforeMeta = false; + faulty.afterRelation = true; + assertThrows( + IllegalStateException.class, + () -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + Membership committed = service.findMembership(lead.session()).block(); + assertNotNull(committed); + faulty.afterRelation = false; + assertEquals( + committed, + service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().get(0)); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + faulty.afterRelation = true; + assertThrows( + IllegalStateException.class, () -> service.bindMember("alice", a, worker).block()); + faulty.afterRelation = false; + Membership bound = service.findMembership(worker.session()).block(); + assertEquals(bound, service.bindMember("alice", a, worker).block()); + faulty.afterRelation = true; + assertThrows( + IllegalStateException.class, + () -> service.unbindMember("alice", a, worker.session(), bound.token()).block()); + faulty.afterRelation = false; + assertNull(service.findMembership(worker.session()).block()); + assertFalse(service.unbindMember("alice", a, worker.session(), bound.token()).block()); + } + + @Test + void persistentCasConflictsDoNotMutateStoredReferencesOrFallBackToPut() throws Exception { + FaultStore faulty = useFaultStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + String before = + new ObjectMapper() + .writeValueAsString( + faulty.search(List.of("team-session-membership"), 100, 0)); + faulty.conflict = true; + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + assertThrows( + TeamConflictException.class, () -> service.bindMember("alice", a, worker).block()); + assertEquals(10, faulty.failedCas.get()); + assertEquals( + before, + new ObjectMapper() + .writeValueAsString( + faulty.search(List.of("team-session-membership"), 100, 0))); + assertNull(service.findMembership(worker.session()).block()); + assertThrows( + UnsupportedOperationException.class, + () -> service.listMemberships("alice", a).block().clear()); + } + + @Test + void twoIndependentClientsCompeteForOneSessionAndIdenticalRequestsAreIdempotent() + throws Exception { + RacingStore racing = new RacingStore(); + store = racing; + client = new LocalTeamClient(store); + service = client.sessionMembership("app", Map.of("state", states)); + create(a); + create(b); + service.adoptTeam( + "alice", + a, + "a", + List.of(member(states, "alice", "lead", "one", "leader-agent"))) + .block(); + service.adoptTeam( + "alice", + b, + "b", + List.of(member(states, "alice", "lead", "two", "leader-agent"))) + .block(); + TeamSessionMembership other = + new LocalTeamClient(store).sessionMembership("app", Map.of("state", states)); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + racing.arm(); + CompletableFuture first = + attempt(() -> service.bindMember("alice", a, worker).block()); + CompletableFuture second = + attempt(() -> other.bindMember("alice", b, worker).block()); + Object x = first.get(10, TimeUnit.SECONDS), y = second.get(10, TimeUnit.SECONDS); + assertEquals(1, List.of(x, y).stream().filter(Membership.class::isInstance).count()); + assertEquals( + 1, List.of(x, y).stream().filter(TeamConflictException.class::isInstance).count()); + Membership won = service.findMembership(worker.session()).block(); + service.unbindMember("alice", won.address(), worker.session(), won.token()).block(); + racing.arm(); + first = attempt(() -> service.bindMember("alice", a, worker).block()); + second = attempt(() -> other.bindMember("alice", a, worker).block()); + assertEquals(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)); + } + + @Test + void completeTeamReadBeforeAdoptionCannotEraseItsMarker() throws Exception { + CountDownLatch read = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicBoolean pause = new AtomicBoolean(true); + InMemoryStore concurrent = + new InMemoryStore() { + @Override + public StoreItem get(List ns, String key) { + StoreItem item = super.get(ns, key); + if (key.equals("meta") && pause.compareAndSet(true, false)) { + read.countDown(); + await(release); + } + return item; + } + }; + store = concurrent; + client = new LocalTeamClient(store); + create(a); + service = client.sessionMembership("app", Map.of("state", states)); + CompletableFuture completing = + CompletableFuture.runAsync(() -> client.completeTeam("ns-a", "a").block()); + try { + assertTrue(read.await(5, TimeUnit.SECONDS)); + service.adoptTeam( + "alice", + a, + "a", + List.of(member(states, "alice", "lead", "one", "leader-agent"))) + .block(); + } finally { + release.countDown(); + } + completing.get(10, TimeUnit.SECONDS); + client.completeTeam("ns-a", "a").block(); + assertEquals("Completed", service.getTeam("alice", a).block().info().phase()); + assertEquals(Status.ACTIVE, service.getTeam("alice", a).block().status()); + assertEquals("one", legacy("lead").sessionId()); + } + + @Test + void unenabledLegacyProviderAndRecordsKeepTheirContracts() throws Exception { + BaseStore legacy = + new BaseStore() { + final InMemoryStore delegate = new InMemoryStore(); + + @Override + public StoreItem get(List ns, String key) { + return delegate.get(ns, key); + } + + @Override + public void put(List ns, String key, Map value) { + delegate.put(ns, key, value); + } + + @Override + public List search(List ns, int limit, int offset) { + return delegate.search(ns, limit, offset); + } + + @Override + public void delete(List ns, String key) { + delegate.delete(ns, key); + } + }; + LocalTeamClient old = new LocalTeamClient(legacy); + old.createTeam(new TeamCreateSpec("old", "ns", "objective", "lead", "", List.of())).block(); + assertThrows( + IllegalArgumentException.class, + () -> old.sessionMembership("app", Map.of("state", states))); + old.completeTeam("ns", "old").block(); + assertEquals( + "Completed", + legacy.get(List.of("teams", "ns", "old"), "meta").value().get("phase")); + assertEquals("", old.listMembers("ns", "old").block().get(0).sessionId()); + TeamMemberInfo record = new TeamMemberInfo("worker", "ref", "Working", "s", "byo", false); + assertEquals(record, TeamMemberInfo.fromMap(record.toMap())); + ObjectMapper json = new ObjectMapper(); + assertEquals(record, json.readValue(json.writeValueAsString(record), TeamMemberInfo.class)); + assertEquals(6, record.toMap().size()); + } + + @Test + void adoptedDirectoryIsNotTruncatedAtOneHundredMembers() { + TeamAddress large = new TeamAddress("ns-a", "large"); + List specs = new ArrayList<>(); + for (int i = 0; i < 120; i++) { + specs.add(new TeamMemberSpec("w" + i, "worker-agent", "", "byo")); + } + client.createTeam( + new TeamCreateSpec("large", "ns-a", "objective", "leader-agent", "", specs)) + .block(); + assertEquals(100, client.listMembers("ns-a", "large").block().size()); + service.adoptTeam( + "alice", + large, + "large", + List.of(member(states, "alice", "lead", "one", "leader-agent"))) + .block(); + assertEquals(121, client.listMembers("ns-a", "large").block().size()); + } + + @Test + void corruptOrUnknownFormatsAndUnknownStoreVersionsAreNotOverwritten() { + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + List ns = List.of("teams", "ns-a", "a"); + Map meta = new LinkedHashMap<>(store.get(ns, "meta").value()); + meta.put("sessionMembership", Map.of("schema", 99)); + store.put(ns, "meta", meta); + long version = store.get(ns, "meta").version(); + assertThrows( + IllegalStateException.class, + () -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + assertEquals(version, store.get(ns, "meta").version()); + InMemoryStore unknown = + new InMemoryStore() { + @Override + public StoreItem get(List namespace, String key) { + StoreItem item = super.get(namespace, key); + return item == null ? null : new StoreItem(key, item.value()); + } + }; + LocalTeamClient old = new LocalTeamClient(unknown); + old.createTeam(new TeamCreateSpec("a", "ns", "", "leader-agent", "", List.of())).block(); + assertThrows( + IllegalStateException.class, + () -> + old.sessionMembership("app", Map.of("state", states)) + .adoptTeam("alice", new TeamAddress("ns", "a"), "a", List.of(lead)) + .block()); + } + + @Test + void conflictingPendingAdoptionCanFinishAfterPreviousTeamReleasesSession() { + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + Membership old = + service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().get(0); + assertThrows( + TeamConflictException.class, + () -> service.adoptTeam("alice", b, "b", List.of(lead)).block()); + assertEquals(Status.PENDING, service.getTeam("alice", b).block().status()); + service.unbindMember("alice", a, lead.session(), old.token()).block(); + Membership replacement = + service.adoptTeam("alice", b, "b", List.of(lead)).block().memberships().get(0); + assertNotEquals(old.token(), replacement.token()); + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, lead.session(), old.token()).block()); + assertEquals(replacement, service.findMembership(lead.session()).block()); + } + + @Test + void concurrentSameAdoptionHasOneLogicalIdAndOnePersistentBinding() throws Exception { + RacingStore racing = new RacingStore(); + store = racing; + client = new LocalTeamClient(store); + create(a); + service = client.sessionMembership("app", Map.of("state", states)); + TeamSessionMembership other = + new LocalTeamClient(store).sessionMembership("app", Map.of("state", states)); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + racing.arm(); + CompletableFuture first = + attempt(() -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + CompletableFuture second = + attempt(() -> other.adoptTeam("alice", a, "a", List.of(lead)).block()); + Object x = first.get(10, TimeUnit.SECONDS), y = second.get(10, TimeUnit.SECONDS); + assertTrue(x instanceof TeamView); + assertEquals(x, y); + assertEquals(1, service.listMemberships("alice", a).block().size()); + } + + @Test + void roleMismatchesAndUnknownTeamDoNotGrantAssociations() { + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + assertThrows(IllegalArgumentException.class, () -> service.getTeam("alice", a).block()); + assertThrows( + IllegalArgumentException.class, + () -> + service.adoptTeam( + "alice", + new TeamAddress("ns", "missing"), + "a", + List.of(lead)) + .block()); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "a", List.of()).block()); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + MemberSession wrongRole = + new MemberSession( + "worker", + "alice", + "worker-agent", + Role.LEADER, + member(states, "alice", "worker", "worker", "worker-agent").session()); + assertThrows( + TeamConflictException.class, + () -> service.bindMember("alice", a, wrongRole).block()); + assertNull(service.findMembership(wrongRole.session()).block()); + assertThrows( + TeamConflictException.class, + () -> + client.createTeam( + new TeamCreateSpec( + "a", "ns-a", "", "leader-agent", "", List.of())) + .block()); + } + + @Test + void malformedRelationshipItemDoesNotBecomeAnEmptyWritableRegistry() { + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + List ns = List.of("team-session-membership", "YXBw"); + String key = store.search(ns, 1, 0).get(0).key(); + store.put(ns, key, Map.of("schema", 99, "receipts", Map.of(), "memberships", List.of())); + long version = store.get(ns, key).version(); + assertThrows( + IllegalStateException.class, () -> service.findMembership(lead.session()).block()); + assertThrows( + IllegalStateException.class, () -> service.bindMember("alice", a, lead).block()); + assertEquals(version, store.get(ns, key).version()); + } + + private FaultStore useFaultStore() { + FaultStore faulty = new FaultStore(); + store = faulty; + client = new LocalTeamClient(store); + service = client.sessionMembership("app", Map.of("state", states)); + create(a); + create(b); + return faulty; + } + + private void create(TeamAddress address) { + client.createTeam( + new TeamCreateSpec( + address.teamName(), + address.namespace(), + "objective", + "leader-agent", + "", + List.of(new TeamMemberSpec("worker", "worker-agent", "", "byo")))) + .block(); + } + + private TeamMemberInfo legacy(String alias) { + return client.listMembers("ns-a", "a").block().stream() + .filter(m -> m.memberName().equals(alias)) + .findFirst() + .orElseThrow(); + } + + private static MemberSession member( + AgentStateStore states, String owner, String alias, String sid, String ref) { + states.save(owner, sid, "legacy", new SavedState("original")); + return new MemberSession( + alias, + "definition-owner", + ref, + alias.equals("lead") ? Role.LEADER : Role.WORKER, + new SessionKey("state", owner, sid)); + } + + private static CompletableFuture attempt(Callable operation) { + return CompletableFuture.supplyAsync( + () -> { + try { + return operation.call(); + } catch (Exception error) { + return error; + } + }); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Latch timed out"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(error); + } + } + + private static class FaultStore extends InMemoryStore { + boolean beforeMeta, beforeRelation, afterRelation, conflict; + final AtomicInteger failedCas = new AtomicInteger(); + + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + boolean relation = ns.get(0).equals("team-session-membership"); + if ((!relation && beforeMeta) || (relation && beforeRelation)) { + throw new IllegalStateException("Injected write failure"); + } + if (relation && conflict) { + failedCas.incrementAndGet(); + return false; + } + boolean committed = super.putIfVersion(ns, key, value, version); + if (relation && committed && afterRelation) { + throw new IllegalStateException("Injected response failure after commit"); + } + return committed; + } + } + + private static class RacingStore extends InMemoryStore { + CyclicBarrier barrier; + AtomicInteger writes; + + void arm() { + barrier = new CyclicBarrier(2); + writes = new AtomicInteger(); + } + + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + if (barrier != null + && ns.get(0).equals("team-session-membership") + && writes.incrementAndGet() <= 2) { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + return super.putIfVersion(ns, key, value, version); + } + } + + /** Existing legacy state; membership must not rewrite it. */ + public record SavedState(String value) implements State {} +} diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index cd904b9025..d1e3bc6525 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -47,6 +47,77 @@ Each capability answers one problem; opt in on the builder. | MCP integration & tool allowlist | Declarative MCP servers + allow/deny per tool | `workspace/tools.json` | [Workspace](/v2/en/docs/harness/workspace) | | Channel routing | Session management, per-session concurrency, multi-agent routing, streaming events | `agent.channel(...)` / `GatewayBootstrap` | [Channel](/v2/en/docs/harness/channel) | +## Persistent Team membership for existing Sessions + +`LocalTeamClient.sessionMembership(...)` explicitly enables adoption, binding, queries and safe removal for existing Leader and manually supplied **BYO** member Sessions. A definition's different Sessions may join different Teams; one exact Session may belong to only one Team in the configured relationship domain, including across Team namespaces. This programming API requires a `VersionedBaseStore`; the existing `InMemoryStore` and JDBC `JdbcStore` implement that capability. Other `BaseStore` and `TeamClient` implementations continue to work through their existing APIs. + +Create the Team through the existing `createTeam`, then adopt it with an existing persisted Leader Session. Bind only members already declared in that Team's directory. The example uses `.block()` at an application entry point; compose the returned `Mono` operations instead on reactive request paths. + +```java +import io.agentscope.core.state.AgentState; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; +import io.agentscope.harness.agent.team.LocalTeamClient; +import io.agentscope.harness.agent.team.TeamCreateSpec; +import io.agentscope.harness.agent.team.TeamMemberSpec; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import java.util.List; +import java.util.Map; + +var stateStore = new InMemoryAgentStateStore(); +stateStore.save("alice", "leader-session", "agent_state", + AgentState.builder().userId("alice").sessionId("leader-session").build()); +stateStore.save("alice", "worker-session", "agent_state", + AgentState.builder().userId("alice").sessionId("worker-session").build()); + +var client = new LocalTeamClient(new InMemoryStore()); +client.createTeam(new TeamCreateSpec( + "research-address", "my-app", "Research a topic", "leader-agent", "", + List.of(new TeamMemberSpec("researcher", "research-agent", "", "byo")))) + .block(); + +var membership = client.sessionMembership("app-relations", Map.of("sessions", stateStore)); +var address = new TeamAddress("my-app", "research-address"); +var leader = new MemberSession("lead", "alice", "leader-agent", Role.LEADER, + new SessionKey("sessions", "alice", "leader-session")); +var worker = new MemberSession("researcher", "alice", "research-agent", Role.WORKER, + new SessionKey("sessions", "alice", "worker-session")); + +var team = membership.adoptTeam("alice", address, "Research", List.of(leader)).block(); +var binding = membership.bindMember("alice", address, worker).block(); +var current = membership.findMembership(worker.session()).block(); +var members = membership.listMemberships("alice", address).block(); +boolean removed = membership.unbindMember("alice", address, worker.session(), binding.token()).block(); +// The existing worker Session remains; findMembership now completes empty. +boolean stateKept = stateStore.exists("alice", "worker-session"); +``` + +The in-memory example demonstrates the operations without models or Worker execution. For process-restart persistence, configure the existing JDBC `JdbcStore` with a durable database and use the same relationship domain and Session-store domain mapping on every application instance. Membership does not write or delete `AgentStateStore` state; a file or JDBC state store can retain the existing conversations independently. + +### Identity and ownership + +- `TeamAddress(namespace, teamName)` is the existing storage/routing address. Supply it explicitly; adoption rejects null/blank namespaces instead of silently moving historical records. Nonblank strings are preserved. Logical `teamId` and `displayName` are separate: two Teams may have the same display name when created at distinct addresses. One address can be adopted by only one owner and relationship domain. +- `SessionKey(stateStoreDomain, owner, sessionId)` identifies the actual Session slot. Use the same stable domain ID for the same physical store partition, and different IDs for independent partitions. Assigning two IDs to the same backing store bypasses this identity contract. Provider-specific aliases must also be resolved by the application; Agent references do not establish storage isolation. +- Session owner must equal Team owner. Definition owner is recorded separately and may differ; this trusted application API does not establish definition access or invitation authorization. Session existence means any persisted state, including legacy keys. A Session with no persisted state cannot be registered. +- Null owner supports anonymous Sessions. Blank owners and the reserved `__anon__` literal are rejected by this new API to avoid aliasing existing anonymous state slots. Old APIs retain their accepted inputs. IDs and addresses reject blank values and control characters. + +### Queries, retries and compatibility + +`getTeam(owner, address)` returns the stable logical ID, display name, original `TeamInfo`, adoption status and current associations. `findMembership(session)` completes empty when unbound; `listMemberships` returns an immutable current list, including the Leader. These are associated Sessions, not the declared member directory or proof that a member is running. + +Adoption writes a marker to the existing Team meta first, then atomically commits initial associations and a receipt in one owner item. A failure between writes leaves `PENDING`: `getTeam` reports it with no committed members, and both `listMemberships` and legacy `listMembers` fail explicitly. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. + +An identical bind retains its token. Unbinding requires the expected token and returns `true` when removed, or `false` when already absent. A wrong Team or stale token fails; an actual unbind followed by a new bind produces a new token. CAS contention retries at most ten times, then raises `TeamConflictException`; storage errors propagate without unconditional-write fallback. If the response fails after a commit, query or retry the identical request to determine the outcome. Cancellation does not roll back a committed association. + +For adopted Teams, legacy `listMembers` reads the same associations and projects `sessionId`; an unbound declared member has an empty `sessionId`. The old record shape, declared `isLead`, `phase` and `deployMode` remain unchanged. Existing nonempty legacy Session fields require explicit full identities in the initial adoption request. `completeTeam` still changes only the original phase to `Completed`, preserving associations and the adoption marker. Versioned stores retry completion CAS rather than overwriting a concurrent update; persistent contention can now raise a conflict. Unenabled nonversioned stores retain their completion fallback. + +Team metadata, phase and declared definitions remain in their existing records. Only relationship links and adoption receipts are kept in one item per relationship domain and owner; read/write cost and contention grow with that owner's links. All writers to adopted records must honor the versioned capability; direct raw mutation or deletion is unsupported. Malformed or unknown relationship formats fail rather than being reset. Session existence validation and relationship commit are separate operations, so external Session deletion can leave an association that the application must explicitly remove. + +Unbinding preserves the Agent definition, Session state and workspace. It does **not** stop runs, revoke tools, update a constructed static `TeamContext`, or unregister `bindSession` notifications. Task boards, mailboxes and static `teamsMode` keep their existing runtime behavior. This capability does not create models, Workers, invitations, schedules or Service Team/Run resources. + ## How state flows Three layers exist; the framework moves data between them automatically. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index bfa028898b..b710423733 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -46,6 +46,77 @@ Harness 在构建期按固定顺序串起所有内置 middleware。你通过 `.m | MCP 集成与工具白名单 | 声明式 MCP server + 工具粒度允许 / 拒绝 | `workspace/tools.json` | [工作区](/v2/zh/docs/harness/workspace) | | Channel 路由 | 会话管理、per-session 并发控制、多 agent 路由、流式事件 | `agent.channel(...)` / `GatewayBootstrap` | [Channel](/v2/zh/docs/harness/channel) | +## 既有 Session 的持久 Team 归属 + +`LocalTeamClient.sessionMembership(...)` 显式启用既有 Leader 和手工提供的 **BYO** 成员 Session 的接入、登记、查询与安全解除。同一定义的不同 Session 可以加入不同 Team;同一完整身份的 Session 在配置的关系域中只能属于一个 Team,包括跨 Team namespace 的情况。这是编程 API,需要 `VersionedBaseStore`;已有 `InMemoryStore` 和 JDBC `JdbcStore` 实现了该能力。其他 `BaseStore` 和 `TeamClient` 仍可通过原有 API 使用。 + +先通过已有 `createTeam` 建立 Team,再用已持久化的 Leader Session 显式接入。只能绑定已在该 Team 目录中声明的成员。下面在应用入口使用 `.block()`;响应式请求路径应组合返回的 `Mono`。 + +```java +import io.agentscope.core.state.AgentState; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; +import io.agentscope.harness.agent.team.LocalTeamClient; +import io.agentscope.harness.agent.team.TeamCreateSpec; +import io.agentscope.harness.agent.team.TeamMemberSpec; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import java.util.List; +import java.util.Map; + +var stateStore = new InMemoryAgentStateStore(); +stateStore.save("alice", "leader-session", "agent_state", + AgentState.builder().userId("alice").sessionId("leader-session").build()); +stateStore.save("alice", "worker-session", "agent_state", + AgentState.builder().userId("alice").sessionId("worker-session").build()); + +var client = new LocalTeamClient(new InMemoryStore()); +client.createTeam(new TeamCreateSpec( + "research-address", "my-app", "Research a topic", "leader-agent", "", + List.of(new TeamMemberSpec("researcher", "research-agent", "", "byo")))) + .block(); + +var membership = client.sessionMembership("app-relations", Map.of("sessions", stateStore)); +var address = new TeamAddress("my-app", "research-address"); +var leader = new MemberSession("lead", "alice", "leader-agent", Role.LEADER, + new SessionKey("sessions", "alice", "leader-session")); +var worker = new MemberSession("researcher", "alice", "research-agent", Role.WORKER, + new SessionKey("sessions", "alice", "worker-session")); + +var team = membership.adoptTeam("alice", address, "Research", List.of(leader)).block(); +var binding = membership.bindMember("alice", address, worker).block(); +var current = membership.findMembership(worker.session()).block(); +var members = membership.listMemberships("alice", address).block(); +boolean removed = membership.unbindMember("alice", address, worker.session(), binding.token()).block(); +// The existing worker Session remains; findMembership now completes empty. +boolean stateKept = stateStore.exists("alice", "worker-session"); +``` + +内存示例不需要模型或 Worker 运行,展示的是实际归属操作。需要跨进程重启持久保存时,使用已有 JDBC `JdbcStore` 与持久数据库,并在所有应用实例使用相同关系域和 Session 存储域映射。归属操作不写入或删除 `AgentStateStore` 状态;文件或 JDBC 状态存储可以独立保留已有对话。 + +### 身份与所有权 + +- `TeamAddress(namespace, teamName)` 是已有存储/路由地址。接入时显式提供,拒绝 null/空白 namespace,避免静默迁移历史记录;非空白字符串原样保留。逻辑 `teamId` 和 `displayName` 独立;不同地址的 Team 可以使用相同展示名。同一地址只能由一个 owner 和关系域接入。 +- `SessionKey(stateStoreDomain, owner, sessionId)` 指向真实 Session 存储槽。同一物理存储分区使用相同稳定域 ID,独立分区使用不同 ID。将同一底层存储配置成两个域 ID 会绕过这一身份合同。应用也须处理 provider 的别名;Agent 引用不能证明状态隔离。 +- Session owner 必须等于 Team owner。定义 owner 独立记录,可以不同;可信应用编程 API 不提供定义访问或邀请授权。存在校验接受任何已持久化状态,包括旧 state keys;没有持久状态的 Session 不能登记。 +- null owner 支持匿名 Session。新 API 拒绝空白 owner 和保留字面值 `__anon__`,避免与已有匿名状态槽混同;旧 API 的已接受输入不变。标识和地址拒绝空白值及控制字符。 + +### 查询、重试与兼容 + +`getTeam(owner, address)` 返回稳定逻辑 ID、展示名、原有 `TeamInfo`、接入状态和当前关系。`findMembership(session)` 在未关联时以 empty 完成;`listMemberships` 返回含 Leader 的不可变当前列表。这些是已关联的 Session,与声明成员目录不同,也不证明成员正在运行。 + +接入先在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。写入中间失败会留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 及旧 `listMembers` 明确报错。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 + +完全相同的绑定保留 token。解除需要预期 token;实际解除返回 `true`,已无关联返回 `false`。错误 Team 或过期 token 报错;真正解除后重绑会生成新 token。CAS 竞争最多重试十次,之后抛 `TeamConflictException`;存储错误直接传播,不降级为无条件写。提交后响应失败时,可查询或用相同请求重试确认结果;取消订阅不回滚已经提交的关系。 + +已接入 Team 的旧 `listMembers` 读取同一关系并投影 `sessionId`,未关联的声明成员投影为空。旧 record 结构、声明的 `isLead`、`phase` 和 `deployMode` 不变。旧数据已有非空 Session 字段时,初始接入必须显式提供对应完整身份。`completeTeam` 仍只将原 phase 改为 `Completed`,保留关系及接入标记。版本存储改为重试完成 CAS,避免覆盖并发更新;持续竞争时可能报冲突。未启用的非版本存储保留旧完成 fallback。 + +Team 元数据、phase 和声明定义继续使用原记录;每个关系域、每个 owner 的单条记录只保存关联和接入回执,读写成本与竞争随该 owner 的关联数增加。已接入记录的所有写入者须遵守版本能力;不支持直接原始修改或删除。损坏或未知关系格式会报错,不重置为空。Session 存在校验与关系提交是独立操作,外部删除 Session 可能留下关联,应用须显式解除。 + +解除保留 Agent 定义、Session 状态和 workspace,不停止运行、撤销工具权限、更新已构造的静态 `TeamContext` 或注销 `bindSession` 通知。任务板、mailbox 和静态 `teamsMode` 保留原运行语义。该能力不创建模型、Worker、邀请、调度或 Service Team/Run 资源。 + ## 状态怎么流转 状态分三层,框架自动在层之间搬数据。 From 9f986423e0b56cd823d8a43b206f2ba59b0f5868 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 10:02:34 +0800 Subject: [PATCH 02/13] fix(harness): bound team membership storage keys --- .../store/TeamSessionMembershipH2Test.java | 143 +++++++++++++++++- .../agent/team/TeamSessionMembership.java | 46 +++++- .../agent/team/TeamSessionMembershipTest.java | 54 ++++++- docs/v2/en/docs/harness/architecture.md | 4 +- docs/v2/zh/docs/harness/architecture.md | 4 +- 5 files changed, 238 insertions(+), 13 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java index 18b94eeb19..db66c2172b 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -48,15 +48,146 @@ import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; class TeamSessionMembershipH2Test { @TempDir Path temp; private final TeamAddress a = new TeamAddress("ns-a", "a"); private final TeamAddress b = new TeamAddress("ns-b", "b"); + @ParameterizedTest + @MethodSource("ownerBoundaries") + void longOwnersCanAdoptReopenQueryAndUnbindWithoutLosingSessionState(String owner) + throws Exception { + Fixture initial = open(); + try { + create(initial.client(), a); + MemberSession lead = member(initial, owner, "lead", "leader-session", "leader-agent"); + MemberSession worker = + member(initial, owner, "worker", "worker-session", "worker-agent"); + assertTrue(initial.states().exists(owner, lead.session().sessionId())); + assertEquals( + Status.ACTIVE, + initial.membership() + .adoptTeam(owner, a, "display", List.of(lead)) + .block() + .status()); + Membership binding = initial.membership().bindMember(owner, a, worker).block(); + shutdown(initial.ds()); + + Fixture reopened = open(); + assertEquals(binding, reopened.membership().findMembership(worker.session()).block()); + assertEquals(2, reopened.membership().listMemberships(owner, a).block().size()); + assertEquals( + "worker-session", + reopened.client().listMembers("ns-a", "a").block().stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + assertTrue( + reopened.membership() + .unbindMember(owner, a, worker.session(), binding.token()) + .block()); + assertNull(reopened.membership().findMembership(worker.session()).block()); + assertEquals( + new SavedState("original"), + reopened.states() + .get(owner, "worker-session", "legacy", SavedState.class) + .orElseThrow()); + } finally { + shutdown(initial.ds()); + } + } + + private static Stream ownerBoundaries() { + return Stream.of( + "a".repeat(186), + "a".repeat(187), + "a".repeat(188), + "中".repeat(62), + "中".repeat(63), + "中".repeat(64)); + } + + @Test + void unstorableOwnerRecordFailsBeforeAdoptionAndLeavesLegacyQueriesAvailable() + throws Exception { + Fixture fixture = open(); + try { + create(fixture.client(), a); + String owner = "a".repeat(187); + MemberSession lead = member(fixture, owner, "lead", "one", "leader-agent"); + String table = AbstractJdbcDialect.from(fixture.ds()).build().storeTableName(); + try (Connection connection = fixture.ds().getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("ALTER TABLE " + table + " ALTER COLUMN item_key VARCHAR(32)"); + } + for (int retry = 0; retry < 2; retry++) { + assertThrows( + RuntimeException.class, + () -> + fixture.membership() + .adoptTeam(owner, a, "display", List.of(lead)) + .block()); + assertFalse( + fixture.store() + .get(List.of("teams", "ns-a", "a"), "meta") + .value() + .containsKey("sessionMembership")); + assertEquals( + "", fixture.client().listMembers("ns-a", "a").block().get(0).sessionId()); + assertTrue(fixture.states().exists(owner, "one")); + } + try (Connection connection = fixture.ds().getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("ALTER TABLE " + table + " ALTER COLUMN item_key VARCHAR(255)"); + } + assertEquals( + Status.ACTIVE, + fixture.membership() + .adoptTeam(owner, a, "display", List.of(lead)) + .block() + .status()); + } finally { + shutdown(fixture.ds()); + } + } + + @Test + void longRelationshipDomainFitsJdbcNamespaceAndSurvivesReopen() throws Exception { + Fixture initial = open(); + String domain = "域".repeat(600); + try { + create(initial.client(), a); + MemberSession lead = member(initial, "lead", "one", "leader-agent"); + TeamSessionMembership membership = + initial.client() + .sessionMembership(domain, Map.of("sessions", initial.states())); + Membership binding = + membership + .adoptTeam("alice", a, "a", List.of(lead)) + .block() + .memberships() + .get(0); + shutdown(initial.ds()); + Fixture reopened = open(); + membership = + reopened.client() + .sessionMembership(domain, Map.of("sessions", reopened.states())); + assertEquals(binding, membership.findMembership(lead.session()).block()); + assertEquals( + "one", reopened.client().listMembers("ns-a", "a").block().get(0).sessionId()); + } finally { + shutdown(initial.ds()); + } + } + @Test void fileDatabaseReopenPreservesMembershipTokensMetadataAndExistingSessionState() throws Exception { @@ -176,7 +307,8 @@ void pendingAdoptionSurvivesDatabaseCloseAndRetryCommitsWithoutDeletingState() @Override public boolean putIfVersion( List ns, String key, Map value, long version) { - if (ns.get(0).equals("team-session-membership")) { + if (ns.get(0).equals("team-session-membership") + && !((Map) value.get("receipts")).isEmpty()) { throw new IllegalStateException("Injected relation write failure"); } return super.putIfVersion(ns, key, value, version); @@ -255,13 +387,18 @@ private static void create(LocalTeamClient client, TeamAddress address) { private static MemberSession member( Fixture fixture, String name, String sessionId, String agentRef) { - fixture.states().save("alice", sessionId, "legacy", new SavedState("original")); + return member(fixture, "alice", name, sessionId, agentRef); + } + + private static MemberSession member( + Fixture fixture, String owner, String name, String sessionId, String agentRef) { + fixture.states().save(owner, sessionId, "legacy", new SavedState("original")); return new MemberSession( name, "definition-owner", agentRef, name.equals("lead") ? Role.LEADER : Role.WORKER, - new SessionKey("sessions", "alice", sessionId)); + new SessionKey("sessions", owner, sessionId)); } private static TeamSessionMembership racing( diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index 1662e32b43..b14e991790 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -23,11 +23,14 @@ import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Base64; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -54,6 +57,10 @@ public final class TeamSessionMembership { private static final String MARKER = "sessionMembership"; private static final int MAX_ATTEMPTS = 10; + // Keep the full owner key within VARCHAR(255), including the "owner-" prefix. + private static final int MAX_ENCODED_OWNER_LENGTH = 249; + // Leave room for the namespace prefix and separator in MySQL's VARCHAR(512). + private static final int MAX_ENCODED_DOMAIN_LENGTH = 480; private static final ObjectMapper JSON = new ObjectMapper() .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) @@ -241,6 +248,7 @@ public Mono adoptTeam( break; } validateInitial(owner, address, initial); + prepareOwner(owner); Marker next = new Marker( 1, @@ -516,6 +524,22 @@ private OwnerItem readOwner(String owner) { return readOwner(store, domain, owner); } + private void prepareOwner(String owner) { + // Check actual storage before enabling projection from the Team's adoption marker. + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + OwnerItem current = readOwner(owner); + if (current.version() > 0 + || writeOwner( + owner, + current, + current.state().receipts(), + current.state().memberships())) { + return; + } + } + throw contention(); + } + private static OwnerItem readOwner(BaseStore store, String domain, String owner) { StoreItem item = store.get(relationNs(domain), ownerKey(owner)); if (item == null) { @@ -719,17 +743,27 @@ private static List memberNs(TeamAddress address) { } private static List relationNs(String domain) { - return List.of("team-session-membership", encoded(domain)); + return List.of("team-session-membership", encoded(domain, MAX_ENCODED_DOMAIN_LENGTH)); } private static String ownerKey(String owner) { - return owner == null ? "anonymous" : "owner-" + encoded(owner); + return owner == null ? "anonymous" : "owner-" + encoded(owner, MAX_ENCODED_OWNER_LENGTH); } - private static String encoded(String value) { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + private static String encoded(String value, int limit) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + if (encoded.length() <= limit) { + return encoded; + } + // ':' cannot appear in the original URL-safe Base64 addresses; short keys stay compatible. + try { + return "sha256:" + + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException( + "SHA-256 is required for membership storage addresses", error); + } } private static String string(Object value) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index 8c0fd61bdc..2863ca7128 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -257,6 +257,55 @@ void adoptionRequiresExplicitIdentitiesForLegacyNonemptySessionFields() { () -> service.adoptTeam("alice", a, "renamed", List.of(lead, old)).block()); } + @Test + void longOwnerAndDomainAddressesKeepDistinctSessionScopes() { + String owner = "a".repeat(187), otherOwner = owner + "b"; + MemberSession first = member(states, owner, "lead", "same", "leader-agent"); + MemberSession second = member(states, otherOwner, "lead", "same", "leader-agent"); + Membership one = + service.adoptTeam(owner, a, "a", List.of(first)).block().memberships().get(0); + Membership two = + service.adoptTeam(otherOwner, b, "b", List.of(second)).block().memberships().get(0); + assertNotEquals(one.teamId(), two.teamId()); + assertEquals(one, service.findMembership(first.session()).block()); + assertEquals(two, service.findMembership(second.session()).block()); + + String domain = "域".repeat(600); + TeamSessionMembership left = client.sessionMembership(domain, Map.of("state", states)); + TeamSessionMembership right = + client.sessionMembership(domain + "b", Map.of("state", states)); + TeamAddress c = new TeamAddress("ns-c", "c"), d = new TeamAddress("ns-d", "d"); + create(c); + create(d); + Membership x = left.adoptTeam(owner, c, "c", List.of(first)).block().memberships().get(0); + Membership y = right.adoptTeam(owner, d, "d", List.of(first)).block().memberships().get(0); + assertNotEquals(x.teamId(), y.teamId()); + assertEquals(x, left.findMembership(first.session()).block()); + assertEquals(y, right.findMembership(first.session()).block()); + assertTrue(left.unbindMember(owner, c, first.session(), x.token()).block()); + assertEquals(y, right.findMembership(first.session()).block()); + } + + @Test + void ownerInitializationContentionDoesNotAdoptTeamOrBlockLegacyQueries() { + FaultStore faulty = useFaultStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + faulty.conflict = true; + assertThrows( + TeamConflictException.class, + () -> service.adoptTeam("alice", a, "a", List.of(lead)).block()); + assertEquals(10, faulty.failedCas.get()); + assertFalse( + faulty.get(List.of("teams", "ns-a", "a"), "meta") + .value() + .containsKey("sessionMembership")); + assertEquals("", legacy("lead").sessionId()); + assertNull(service.findMembership(lead.session()).block()); + faulty.conflict = false; + assertEquals( + Status.ACTIVE, service.adoptTeam("alice", a, "a", List.of(lead)).block().status()); + } + @Test void pendingAdoptionAfterFailureIsObservableAndRecoverableByNewClient() { FaultStore faulty = useFaultStore(); @@ -676,7 +725,8 @@ private static class FaultStore extends InMemoryStore { public boolean putIfVersion( List ns, String key, Map value, long version) { boolean relation = ns.get(0).equals("team-session-membership"); - if ((!relation && beforeMeta) || (relation && beforeRelation)) { + boolean association = relation && !((Map) value.get("receipts")).isEmpty(); + if ((!relation && beforeMeta) || (association && beforeRelation)) { throw new IllegalStateException("Injected write failure"); } if (relation && conflict) { @@ -684,7 +734,7 @@ public boolean putIfVersion( return false; } boolean committed = super.putIfVersion(ns, key, value, version); - if (relation && committed && afterRelation) { + if (association && committed && afterRelation) { throw new IllegalStateException("Injected response failure after commit"); } return committed; diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index d1e3bc6525..16ebd1bcac 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -108,7 +108,9 @@ The in-memory example demonstrates the operations without models or Worker execu `getTeam(owner, address)` returns the stable logical ID, display name, original `TeamInfo`, adoption status and current associations. `findMembership(session)` completes empty when unbound; `listMemberships` returns an immutable current list, including the Leader. These are associated Sessions, not the declared member directory or proof that a member is running. -Adoption writes a marker to the existing Team meta first, then atomically commits initial associations and a receipt in one owner item. A failure between writes leaves `PENDING`: `getTeam` reports it with no committed members, and both `listMemberships` and legacy `listMembers` fail explicitly. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. +Before writing an adoption marker, adoption ensures an empty owner item can be stored with CAS. A storage failure at this stage leaves the Team unadopted and legacy queries available; the empty item can be retained for a retry. Relationship addresses retain existing short encodings and use fixed-length SHA-256 encodings for long owners or domains, keeping within JDBC key and namespace limits. + +Adoption then writes a marker to the existing Team meta and atomically commits initial associations and a receipt in one owner item. A failure between these writes leaves `PENDING`: `getTeam` reports it with no committed members, and both `listMemberships` and legacy `listMembers` fail explicitly. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. An identical bind retains its token. Unbinding requires the expected token and returns `true` when removed, or `false` when already absent. A wrong Team or stale token fails; an actual unbind followed by a new bind produces a new token. CAS contention retries at most ten times, then raises `TeamConflictException`; storage errors propagate without unconditional-write fallback. If the response fails after a commit, query or retry the identical request to determine the outcome. Cancellation does not roll back a committed association. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index b710423733..cb66f27ce0 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -107,7 +107,9 @@ boolean stateKept = stateStore.exists("alice", "worker-session"); `getTeam(owner, address)` 返回稳定逻辑 ID、展示名、原有 `TeamInfo`、接入状态和当前关系。`findMembership(session)` 在未关联时以 empty 完成;`listMemberships` 返回含 Leader 的不可变当前列表。这些是已关联的 Session,与声明成员目录不同,也不证明成员正在运行。 -接入先在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。写入中间失败会留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 及旧 `listMembers` 明确报错。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 +写接入标记前,接入操作先通过 CAS 确保空 owner 记录可以存储。此阶段存储失败时 Team 尚未接入,旧查询仍可用;空记录可以保留供重试。关系地址保留已有短编码,过长 owner 或关系域改用定长 SHA-256 编码,满足 JDBC 键及 namespace 的长度限制。 + +随后在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。这两次写入中间失败会留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 及旧 `listMembers` 明确报错。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 完全相同的绑定保留 token。解除需要预期 token;实际解除返回 `true`,已无关联返回 `false`。错误 Team 或过期 token 报错;真正解除后重绑会生成新 token。CAS 竞争最多重试十次,之后抛 `TeamConflictException`;存储错误直接传播,不降级为无条件写。提交后响应失败时,可查询或用相同请求重试确认结果;取消订阅不回滚已经提交的关系。 From 2aaa2bc392fd418858cde93bda8ed5856dfedff3 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 10:57:50 +0800 Subject: [PATCH 03/13] fix(jdbc): propagate non-duplicate CAS insert failures --- .../extensions/jdbc/store/JdbcStore.java | 22 +- .../jdbc/store/JdbcStoreConstraintTest.java | 198 ++++++++++++++++++ .../store/TeamSessionMembershipH2Test.java | 65 ++++++ 3 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java index 58a58b2040..535b52f768 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java @@ -26,7 +26,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -125,8 +124,6 @@ public boolean putIfVersion( bindParams(ps, boundSql.params()); ps.executeUpdate(); return true; - } catch (SQLIntegrityConstraintViolationException dup) { - return false; } catch (SQLException e) { if (isDuplicateKey(e)) { return false; @@ -249,14 +246,25 @@ private static void validateKey(String key) { } private static boolean isDuplicateKey(SQLException e) { - if (e instanceof SQLIntegrityConstraintViolationException) { + if ("23505".equals(e.getSQLState())) { return true; } - String state = e.getSQLState(); - if (state != null && state.startsWith("23")) { + if ("23000".equals(e.getSQLState()) && e.getErrorCode() == 1062) { return true; } - return e.getErrorCode() == 19 && e.getClass().getName().startsWith("org.sqlite."); + if (e.getErrorCode() == 19 && e.getClass().getName().startsWith("org.sqlite.")) { + // SQLite collapses all constraints into error 19. Keep its optional driver unlinked. + try { + Object result = e.getClass().getMethod("getResultCode").invoke(e); + if (result instanceof Enum code) { + return "SQLITE_CONSTRAINT_PRIMARYKEY".equals(code.name()) + || "SQLITE_CONSTRAINT_UNIQUE".equals(code.name()); + } + } catch (ReflectiveOperationException ignored) { + // An unknown constraint is a storage failure, not evidence of a duplicate key. + } + } + return false; } // ------------------------------------------------------------------------- diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java new file mode 100644 index 0000000000..5386c5a409 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java @@ -0,0 +1,198 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.jdbc.store; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.extensions.jdbc.H2TestSupport; +import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; +import io.agentscope.extensions.jdbc.dialect.vendor.H2Dialect; +import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.sql.DataSource; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sqlite.SQLiteDataSource; +import org.sqlite.SQLiteErrorCode; +import org.sqlite.SQLiteException; + +class JdbcStoreConstraintTest { + @TempDir Path temp; + private final List ns = List.of("test"); + + @ParameterizedTest + @ValueSource(strings = {"h2", "sqlite"}) + void duplicatePrimaryKeyReturnsFalseAndPreservesExistingItem(String database) throws Exception { + JdbcStore store = open(database, ""); + assertTrue(store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + StoreItem before = store.get(ns, "key"); + assertFalse(store.putIfVersion(ns, "key", Map.of("value", "replacement"), 0)); + assertEquals(before, store.get(ns, "key")); + } + + @ParameterizedTest + @ValueSource(strings = {"h2", "sqlite"}) + void checkViolationsPropagateOnInsertAndUpdateWithoutChangingState(String database) + throws Exception { + JdbcStore store = open(database, "CHECK (value_json NOT LIKE '%rejected%')"); + IllegalStateException insert = + assertThrows( + IllegalStateException.class, + () -> store.putIfVersion(ns, "key", Map.of("value", "rejected"), 0)); + assertInstanceOf(SQLException.class, insert.getCause()); + assertNull(store.get(ns, "key")); + assertTrue(store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + StoreItem before = store.get(ns, "key"); + IllegalStateException update = + assertThrows( + IllegalStateException.class, + () -> + store.putIfVersion( + ns, "key", Map.of("value", "rejected"), before.version())); + assertInstanceOf(SQLException.class, update.getCause()); + assertEquals(before, store.get(ns, "key")); + } + + @ParameterizedTest + @ValueSource(strings = {"h2", "sqlite"}) + void foreignKeyViolationsPropagateWithoutCreatingItem(String database) throws Exception { + JdbcStore store = + open(database, "FOREIGN KEY (item_key) REFERENCES allowed_keys(item_key)"); + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + store.putIfVersion( + ns, "missing-parent", Map.of("value", "original"), 0)); + assertInstanceOf(SQLException.class, error.getCause()); + assertNull(store.get(ns, "missing-parent")); + } + + @ParameterizedTest + @ValueSource(strings = {"h2", "sqlite"}) + void notNullViolationsPropagateWithoutCreatingItem(String database) throws Exception { + JdbcStore store = open(database, "required_value INTEGER NOT NULL"); + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + assertInstanceOf(SQLException.class, error.getCause()); + assertNull(store.get(ns, "key")); + } + + @ParameterizedTest + @MethodSource("sqlErrors") + void onlySpecificDuplicateSignalsReturnFalse(SQLException sqlError, boolean duplicate) + throws Exception { + DataSource ds = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(ds.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenThrow(sqlError); + JdbcStore store = JdbcStore.builder(ds).dialect(new H2Dialect()).build(); + if (duplicate) { + assertFalse(store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + } else { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + assertSame(sqlError, failure.getCause()); + } + } + + private static Stream sqlErrors() { + return Stream.of( + Arguments.of(new SQLException("duplicate", "23505", 0), true), + Arguments.of( + new SQLIntegrityConstraintViolationException("duplicate", "23000", 1062), + true), + Arguments.of( + new SQLiteException( + "duplicate", SQLiteErrorCode.SQLITE_CONSTRAINT_PRIMARYKEY), + true), + Arguments.of( + new SQLiteException("duplicate", SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE), + true), + Arguments.of( + new SQLiteException( + "unknown constraint", SQLiteErrorCode.SQLITE_CONSTRAINT), + false), + Arguments.of( + new SQLIntegrityConstraintViolationException("check", "23513", 23513), + false), + Arguments.of( + new SQLIntegrityConstraintViolationException("foreign key", "23000", 1452), + false), + Arguments.of( + new SQLIntegrityConstraintViolationException("not null", "23000", 1048), + false), + Arguments.of(new SQLException("generic constraint", "23000", 0), false), + Arguments.of( + new SQLIntegrityConstraintViolationException( + "unknown constraint", null, 19), + false), + Arguments.of(new SQLException("connection failure", "08006", 0), false)); + } + + private JdbcStore open(String database, String extra) throws Exception { + DataSource ds; + if (database.equals("sqlite")) { + SQLiteDataSource sqlite = new SQLiteDataSource(); + sqlite.setUrl("jdbc:sqlite:" + temp.resolve("store.db")); + sqlite.setEnforceForeignKeys(true); + ds = sqlite; + } else { + ds = H2TestSupport.createDataSource("constraints"); + } + AbstractJdbcDialect dialect = AbstractJdbcDialect.from(ds).build(); + try (Connection connection = ds.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE " + dialect.storeTableName()); + statement.execute("CREATE TABLE allowed_keys (item_key VARCHAR(255) PRIMARY KEY)"); + statement.execute( + "CREATE TABLE " + + dialect.storeTableName() + + " (namespace_path VARCHAR(2048) NOT NULL, item_key VARCHAR(255) NOT" + + " NULL, value_json TEXT NOT NULL, version BIGINT NOT NULL, updated_at" + + " BIGINT NOT NULL," + + (extra.isEmpty() ? "" : extra + ",") + + " PRIMARY KEY (namespace_path, item_key))"); + } + return JdbcStore.builder(ds).dialect(dialect).build(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java index db66c2172b..34951968ae 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -40,6 +41,7 @@ import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; import java.nio.file.Path; import java.sql.Connection; +import java.sql.SQLException; import java.sql.Statement; import java.util.List; import java.util.Map; @@ -159,6 +161,69 @@ void unstorableOwnerRecordFailsBeforeAdoptionAndLeavesLegacyQueriesAvailable() } } + @Test + void checkConstraintFailureIsNotRetriedAsContentionAndLeavesTeamUnadopted() throws Exception { + Fixture fixture = open(); + try { + create(fixture.client(), a); + MemberSession lead = member(fixture, "lead", "one", "leader-agent"); + String table = AbstractJdbcDialect.from(fixture.ds()).build().storeTableName(); + try (Connection connection = fixture.ds().getConnection(); + Statement statement = connection.createStatement()) { + statement.execute( + "ALTER TABLE " + + table + + " ADD CONSTRAINT reject_relation CHECK (item_key NOT LIKE" + + " 'owner-%')"); + } + AtomicInteger writes = new AtomicInteger(); + DelegatingStore counting = + new DelegatingStore(fixture.store()) { + @Override + public boolean putIfVersion( + List ns, + String key, + Map value, + long version) { + if (ns.get(0).equals("team-session-membership")) { + writes.incrementAndGet(); + } + return super.putIfVersion(ns, key, value, version); + } + }; + TeamSessionMembership membership = + new LocalTeamClient(counting) + .sessionMembership("app", Map.of("sessions", fixture.states())); + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + membership + .adoptTeam("alice", a, "display", List.of(lead)) + .block()); + assertInstanceOf(SQLException.class, failure.getCause()); + assertEquals(1, writes.get()); + assertFalse( + fixture.store() + .get(List.of("teams", "ns-a", "a"), "meta") + .value() + .containsKey("sessionMembership")); + assertEquals("", fixture.client().listMembers("ns-a", "a").block().get(0).sessionId()); + assertEquals( + new SavedState("original"), + fixture.states().get("alice", "one", "legacy", SavedState.class).orElseThrow()); + try (Connection connection = fixture.ds().getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("ALTER TABLE " + table + " DROP CONSTRAINT reject_relation"); + } + assertEquals( + Status.ACTIVE, + membership.adoptTeam("alice", a, "display", List.of(lead)).block().status()); + } finally { + shutdown(fixture.ds()); + } + } + @Test void longRelationshipDomainFitsJdbcNamespaceAndSurvivesReopen() throws Exception { Fixture initial = open(); From f00b0fd6b19aa9fd0892f20436576e94d5fe9460 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 12:31:37 +0800 Subject: [PATCH 04/13] fix(harness): tolerate stores that omit null identity fields A JdbcStore ObjectMapper configured with NON_NULL legitimately omits null owner fields, but the strict decoder rejected such committed relationship records, making them unreadable. Nullable fields now decode as null when absent while required fields and schema checks still reject damaged data. --- .../agent/team/TeamSessionMembership.java | 4 +- .../agent/team/TeamSessionMembershipTest.java | 70 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index b14e991790..6a9f3b0bc9 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -61,10 +61,12 @@ public final class TeamSessionMembership { private static final int MAX_ENCODED_OWNER_LENGTH = 249; // Leave room for the namespace prefix and separator in MySQL's VARCHAR(512). private static final int MAX_ENCODED_DOMAIN_LENGTH = 480; + // Missing creator properties are tolerated: a JdbcStore ObjectMapper configured with + // NON_NULL legitimately omits null owner fields. Required fields and schema are still + // enforced by the record compact constructors and explicit schema checks. private static final ObjectMapper JSON = new ObjectMapper() .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - .enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) .enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS); private final VersionedBaseStore store; private final String domain; diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index 2863ca7128..e52add03ab 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -205,6 +205,53 @@ void anonymousSessionsAreSupportedAndReservedAliasesRejected() { () -> service.adoptTeam("alice", b, "b", List.of(anonymous)).block()); } + @Test + @SuppressWarnings("unchecked") + void storesOmittingNullIdentityFieldsRemainReadableAndRequiredFieldsStayEnforced() { + // Mirrors a JdbcStore ObjectMapper configured with JsonInclude.Include.NON_NULL. + store = + new InMemoryStore() { + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + return super.putIfVersion(ns, key, strip(value), version); + } + + @Override + public void put(List ns, String key, Map value) { + super.put(ns, key, strip(value)); + } + }; + client = new LocalTeamClient(store); + create(a); + service = client.sessionMembership("app", Map.of("state", states)); + MemberSession anonymous = + new MemberSession( + "lead", + null, + "leader-agent", + Role.LEADER, + new SessionKey("state", null, "anon")); + states.save(null, "anon", "legacy", new SavedState("original")); + TeamView view = service.adoptTeam(null, a, "anonymous", List.of(anonymous)).block(); + assertEquals(Status.ACTIVE, view.status()); + assertNull(view.teamOwner()); + assertNull(view.memberships().get(0).member().definitionOwner()); + assertEquals("anon", legacy("lead").sessionId()); + Membership bound = service.findMembership(anonymous.session()).block(); + assertEquals(anonymous, bound.member()); + assertTrue(service.unbindMember(null, a, anonymous.session(), bound.token()).block()); + + List ns = List.of("teams", "ns-a", "a"); + Map meta = new LinkedHashMap<>(store.get(ns, "meta").value()); + Map marker = + new LinkedHashMap<>((Map) meta.get("sessionMembership")); + marker.remove("teamId"); + meta.put("sessionMembership", marker); + store.put(ns, "meta", meta); + assertThrows(IllegalStateException.class, () -> service.getTeam(null, a).block()); + } + @Test void requiresRealPersistedSessionsAndMatchingDeclaredDefinitions() { MemberSession missing = @@ -717,6 +764,29 @@ private static void await(CountDownLatch latch) { } } + @SuppressWarnings("unchecked") + private static Map strip(Map value) { + Map out = new LinkedHashMap<>(); + for (Map.Entry entry : value.entrySet()) { + Object v = entry.getValue(); + if (v instanceof Map map) { + out.put(entry.getKey(), strip((Map) map)); + } else if (v instanceof List list) { + List items = new ArrayList<>(); + for (Object item : list) { + items.add( + item instanceof Map map + ? strip((Map) map) + : item); + } + out.put(entry.getKey(), items); + } else if (v != null) { + out.put(entry.getKey(), v); + } + } + return out; + } + private static class FaultStore extends InMemoryStore { boolean beforeMeta, beforeRelation, afterRelation, conflict; final AtomicInteger failedCas = new AtomicInteger(); From d565c8df8cfd01b8c0a05e8d7bd0353f084c9c7a Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 12:31:47 +0800 Subject: [PATCH 05/13] refactor(harness): drop single-value Membership Source enum All associations are application-supplied BYO and no behavior branches on the enum, so remove it and its persisted field before first release. Previously written records remain readable; the unknown field is ignored. --- .../harness/agent/team/TeamSessionMembership.java | 10 ---------- .../harness/agent/team/TeamSessionMembershipTest.java | 2 -- 2 files changed, 12 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index 6a9f3b0bc9..4b8e942fcc 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -89,11 +89,6 @@ public enum Role { WORKER } - /** This capability only associates resources supplied by the application. */ - public enum Source { - BYO - } - /** Adoption is active only after the relationship item contains its commit receipt. */ public enum Status { PENDING, @@ -158,7 +153,6 @@ public record MemberSession( * @param teamId stable logical Team ID * @param address legacy Team address * @param member complete member and Session identity - * @param source resource origin, currently BYO * @param token persistent expected token for safe removal */ public record Membership( @@ -166,7 +160,6 @@ public record Membership( String teamId, TeamAddress address, MemberSession member, - Source source, String token) { /** Validates an association. */ public Membership { @@ -174,7 +167,6 @@ public record Membership( text(teamId, "team ID"); Objects.requireNonNull(address, "address"); Objects.requireNonNull(member, "member"); - Objects.requireNonNull(source, "source"); text(token, "binding token"); sameOwner(teamOwner, member.session()); } @@ -285,7 +277,6 @@ public Mono adoptTeam( marker.teamId(), address, member, - Source.BYO, UUID.randomUUID().toString())); } Map receipts = new HashMap<>(current.state().receipts()); @@ -316,7 +307,6 @@ public Mono bindMember(String owner, TeamAddress address, MemberSess marker.teamId(), address, member, - Source.BYO, UUID.randomUUID().toString()); for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { OwnerItem current = readOwner(owner); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index e52add03ab..2721e66af2 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -35,7 +35,6 @@ import io.agentscope.harness.agent.team.TeamSessionMembership.Membership; import io.agentscope.harness.agent.team.TeamSessionMembership.Role; import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; -import io.agentscope.harness.agent.team.TeamSessionMembership.Source; import io.agentscope.harness.agent.team.TeamSessionMembership.Status; import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; import io.agentscope.harness.agent.team.TeamSessionMembership.TeamView; @@ -85,7 +84,6 @@ void usableLifecycleKeepsLegacyMetadataStateAndWorkspace() throws Exception { Files.writeString(workspace, "existing files"); TeamView view = service.adoptTeam("alice", a, "research", List.of(leader)).block(); Membership bound = service.bindMember("alice", a, worker).block(); - assertEquals(Source.BYO, bound.source()); assertEquals(bound, service.findMembership(worker.session()).block()); assertEquals(2, service.listMemberships("alice", a).block().size()); assertEquals("worker", legacy("worker").sessionId()); From 2ce59b609219090cf051eaf91be64dca83a17581 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 14:29:24 +0800 Subject: [PATCH 06/13] fix(harness): preserve team membership upgrade compatibility --- .../store/TeamSessionMembershipH2Test.java | 85 +++++++++ .../harness/agent/team/LocalTeamClient.java | 22 ++- .../agent/team/TeamSessionMembership.java | 75 +++++++- .../agent/team/LocalTeamClientTest.java | 177 ++++++++++++++++++ .../agent/team/TeamSessionMembershipTest.java | 169 +++++++++++++++++ docs/v2/en/docs/harness/architecture.md | 6 +- docs/v2/zh/docs/harness/architecture.md | 6 +- 7 files changed, 533 insertions(+), 7 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java index 34951968ae..8173d71c87 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -23,6 +23,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import io.agentscope.core.state.State; import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; import io.agentscope.extensions.jdbc.state.JdbcAgentStateStore; @@ -62,6 +64,89 @@ class TeamSessionMembershipH2Test { private final TeamAddress a = new TeamAddress("ns-a", "a"); private final TeamAddress b = new TeamAddress("ns-b", "b"); + @Test + void historicalByoJsonSurvivesFileReopenAndOriginalTokenUnbindPreservesSession() + throws Exception { + // Literal schema=1 JSON from the shape used before the public Source field was removed. + String historicalJson = + """ + { + "meta": { + "objective": "historical objective", "phase": "Working", "leadRef": "leader-agent", + "sessionMembership": { + "schema": 1, "domain": "app", "owner": "alice", "teamId": "historical-team-id", + "displayName": "historical display", "registrationToken": "historical-registration", + "initial": [{ + "memberName": "lead", "definitionOwner": "definition-owner", "agentRef": "leader-agent", + "role": "LEADER", "session": { + "stateStoreDomain": "sessions", "owner": "alice", "sessionId": "leader-session" + } + }] + } + }, + "owner": { + "schema": 1, "receipts": {"historical-team-id": "historical-registration"}, + "memberships": [{ + "teamOwner": "alice", "teamId": "historical-team-id", + "address": {"namespace": "ns-a", "teamName": "a"}, + "member": { + "memberName": "lead", "definitionOwner": "definition-owner", "agentRef": "leader-agent", + "role": "LEADER", "session": { + "stateStoreDomain": "sessions", "owner": "alice", "sessionId": "leader-session" + } + }, + "source": "BYO", "token": "historical-leader-token" + }] + } + } + """; + Map> historical = + new ObjectMapper().readValue(historicalJson, new TypeReference<>() {}); + Fixture fixture = open(); + try { + create(fixture.client(), a); + fixture.states().save("alice", "leader-session", "legacy", new SavedState("original")); + fixture.store().put(List.of("teams", "ns-a", "a"), "meta", historical.get("meta")); + fixture.store() + .put( + List.of("team-session-membership", "YXBw"), + "owner-YWxpY2U", + historical.get("owner")); + shutdown(fixture.ds()); + + fixture = open(); + SessionKey session = new SessionKey("sessions", "alice", "leader-session"); + Membership historicalBinding = fixture.membership().findMembership(session).block(); + assertEquals("historical-leader-token", historicalBinding.token()); + assertEquals("historical-team-id", historicalBinding.teamId()); + assertEquals(Status.ACTIVE, fixture.membership().getTeam("alice", a).block().status()); + assertEquals(1, fixture.membership().listMemberships("alice", a).block().size()); + assertEquals( + "leader-session", + fixture.client().listMembers("ns-a", "a").block().get(0).sessionId()); + assertTrue( + fixture.membership() + .unbindMember("alice", a, session, "historical-leader-token") + .block()); + shutdown(fixture.ds()); + + fixture = open(); + assertNull(fixture.membership().findMembership(session).block()); + assertTrue(fixture.membership().listMemberships("alice", a).block().isEmpty()); + assertEquals("", fixture.client().listMembers("ns-a", "a").block().get(0).sessionId()); + assertEquals( + "historical-team-id", + fixture.membership().getTeam("alice", a).block().teamId()); + assertEquals( + new SavedState("original"), + fixture.states() + .get("alice", "leader-session", "legacy", SavedState.class) + .orElseThrow()); + } finally { + shutdown(fixture.ds()); + } + } + @ParameterizedTest @MethodSource("ownerBoundaries") void longOwnersCanAdoptReopenQueryAndUnbindWithoutLosingSessionState(String owner) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java index f08000b70b..1ac56f220a 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java @@ -39,6 +39,7 @@ public final class LocalTeamClient implements TeamClient { private final BaseStore store; private final AtomicLong messageSeq = new AtomicLong(); + private volatile boolean sessionMembershipEnabled; public LocalTeamClient(BaseStore store) { this.store = Objects.requireNonNull(store, "store"); @@ -47,6 +48,10 @@ public LocalTeamClient(BaseStore store) { /** * Explicitly enables persistent associations for existing Sessions on a versioned store. * + *

A successful call also enables strict completion CAS on this client, including Teams + * awaiting adoption. Enable every writer before adopting Teams; legacy writers cannot safely + * share adopted records. + * * @param relationDomain stable shared App relationship domain, independent of Team namespace * @param stateStores stable state store domain IDs mapped to the actual Session stores; * clients sharing the same physical store must use the same ID @@ -55,7 +60,10 @@ public LocalTeamClient(BaseStore store) { */ public TeamSessionMembership sessionMembership( String relationDomain, Map stateStores) { - return new TeamSessionMembership(store, relationDomain, stateStores); + TeamSessionMembership membership = + new TeamSessionMembership(store, relationDomain, stateStores); + sessionMembershipEnabled = true; + return membership; } @Override @@ -558,7 +566,16 @@ public Mono createTeam(TeamCreateSpec spec) { public Mono completeTeam(String namespace, String teamName) { return Mono.fromRunnable( () -> { - if (store instanceof VersionedBaseStore) { + StoreItem item = store.get(teamNs(namespace, teamName), "meta"); + boolean adopted = + item != null + && item.value() != null + && item.value().containsKey("sessionMembership"); + if (sessionMembershipEnabled || adopted) { + if (!(store instanceof VersionedBaseStore)) { + throw new IllegalStateException( + "Adopted Teams require VersionedBaseStore"); + } // A stale unconditional fallback could erase concurrent adoption. for (int attempt = 0; attempt < 10; attempt++) { StoreItem current = @@ -586,7 +603,6 @@ public Mono completeTeam(String namespace, String teamName) { throw new TeamConflictException( "completeTeam CAS retry limit exceeded"); } - StoreItem item = store.get(teamNs(namespace, teamName), "meta"); Map meta = item == null || item.value() == null ? new LinkedHashMap<>() diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index 4b8e942fcc..05ff4ddf4e 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -537,7 +537,7 @@ private static OwnerItem readOwner(BaseStore store, String domain, String owner) if (item == null) { return new OwnerItem(0, new OwnerState(1, Map.of(), List.of())); } - OwnerState state = decode(item.value(), OwnerState.class); + OwnerState state = decodeOwner(item.value()); if (state.schema() != 1) { throw corrupt(); } @@ -574,7 +574,11 @@ private boolean writeOwner( return store.putIfVersion( relationNs(domain), ownerKey(owner), - encode(new OwnerState(1, receipts, memberships)), + encode( + new StoredOwnerState( + 1, + receipts, + memberships.stream().map(StoredMembership::from).toList())), current.version()); } @@ -708,6 +712,73 @@ private record OwnerState( private record OwnerItem(long version, OwnerState state) {} + // Schema 1 keeps BYO on disk independently of the public Membership shape. + private record StoredOwnerState( + int schema, Map receipts, List memberships) { + StoredOwnerState { + receipts = Map.copyOf(receipts); + memberships = List.copyOf(memberships); + } + } + + private record StoredMembership( + String teamOwner, + String teamId, + TeamAddress address, + MemberSession member, + String source, + String token) { + StoredMembership { + if (!"BYO".equals(source)) { + throw corrupt(); + } + } + + static StoredMembership from(Membership membership) { + return new StoredMembership( + membership.teamOwner(), + membership.teamId(), + membership.address(), + membership.member(), + "BYO", + membership.token()); + } + + Membership membership() { + return new Membership(teamOwner, teamId, address, member, token); + } + } + + private static OwnerState decodeOwner(Map value) { + try { + Map normalized = new LinkedHashMap<>(value); + if (value.get("memberships") instanceof List rows) { + List memberships = new ArrayList<>(rows.size()); + for (Object row : rows) { + if (row instanceof Map) { + Map membership = + JSON.convertValue(row, new TypeReference<>() {}); + // Only omission is legacy BYO; explicit null and unknown sources fail. + if (!membership.containsKey("source")) { + membership.put("source", "BYO"); + } + memberships.add(membership); + } else { + memberships.add(row); + } + } + normalized.put("memberships", memberships); + } + StoredOwnerState stored = decode(normalized, StoredOwnerState.class); + return new OwnerState( + stored.schema(), + stored.receipts(), + stored.memberships().stream().map(StoredMembership::membership).toList()); + } catch (RuntimeException error) { + throw new IllegalStateException("Invalid Session membership data", error); + } + } + private static Map encode(Object value) { return JSON.convertValue(value, new TypeReference<>() {}); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java index fa44d35f9a..bfc4e4735e 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java @@ -19,14 +19,28 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.core.state.State; +import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; +import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Membership; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class LocalTeamClientTest { + private static final List COMPLETION_META = List.of("teams", "ns", "membership"); + @Test void assignThenOwnerClaim_selfClaimRejectedForOthers() { LocalTeamClient client = new LocalTeamClient(new InMemoryStore()); @@ -225,4 +239,167 @@ void concurrentSelfClaim_onlyOneWins() throws Exception { assertEquals(1, conflicts.get()); assertTrue(client.listClaimableTasks("ns", "race").block().isEmpty()); } + + @Test + void completionWithoutMembershipOptInRetainsVersionedStoreFallback() { + CompletionFaultStore store = new CompletionFaultStore(); + LocalTeamClient client = new LocalTeamClient(store); + createCompletionTeam(client); + store.rejectCompletion = true; + + client.completeTeam("ns", "membership").block(); + + assertEquals("Completed", store.get(COMPLETION_META, "meta").value().get("phase")); + assertEquals(1, store.casAttempts); + assertEquals(1, store.unconditionalWrites); + } + + @Test + void rejectedMembershipConfigurationDoesNotChangeLegacyCompletion() { + CompletionFaultStore store = new CompletionFaultStore(); + LocalTeamClient client = new LocalTeamClient(store); + createCompletionTeam(client); + assertThrows(IllegalArgumentException.class, () -> client.sessionMembership("", Map.of())); + store.rejectCompletion = true; + + client.completeTeam("ns", "membership").block(); + + assertEquals("Completed", store.get(COMPLETION_META, "meta").value().get("phase")); + assertEquals(1, store.casAttempts); + assertEquals(1, store.unconditionalWrites); + } + + @Test + void successfulMembershipOptInProtectsUnadoptedMetadataOnContention() { + CompletionFaultStore store = new CompletionFaultStore(); + LocalTeamClient client = new LocalTeamClient(store); + createCompletionTeam(client); + client.sessionMembership("app", Map.of()); + StoreItem before = store.get(COMPLETION_META, "meta"); + store.rejectCompletion = true; + + assertThrows( + TeamConflictException.class, () -> client.completeTeam("ns", "membership").block()); + + assertEquals(before, store.get(COMPLETION_META, "meta")); + assertEquals(10, store.casAttempts); + assertEquals(0, store.unconditionalWrites); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void enabledOrReopenedClientPreservesAdoptedMetadataAndTokenOnContention(boolean reopen) { + CompletionFaultStore store = new CompletionFaultStore(); + LocalTeamClient original = new LocalTeamClient(store); + InMemoryAgentStateStore states = new InMemoryAgentStateStore(); + TeamSessionMembership membership = adoptCompletionTeam(original, states); + SessionKey session = new SessionKey("state", "alice", "leader-session"); + Membership beforeMembership = membership.findMembership(session).block(); + StoreItem beforeMeta = store.get(COMPLETION_META, "meta"); + LocalTeamClient completing = reopen ? new LocalTeamClient(store) : original; + store.rejectCompletion = true; + + assertThrows( + TeamConflictException.class, + () -> completing.completeTeam("ns", "membership").block()); + + assertEquals(beforeMeta, store.get(COMPLETION_META, "meta")); + assertEquals(beforeMembership, membership.findMembership(session).block()); + assertEquals(10, store.casAttempts); + assertEquals(0, store.unconditionalWrites); + } + + @Test + void adoptedMetadataCannotFallBackThroughNonversionedProvider() { + InMemoryStore store = new InMemoryStore(); + adoptCompletionTeam(new LocalTeamClient(store), new InMemoryAgentStateStore()); + StoreItem before = store.get(COMPLETION_META, "meta"); + AtomicInteger writes = new AtomicInteger(); + BaseStore legacy = + new BaseStore() { + @Override + public StoreItem get(List namespace, String key) { + return store.get(namespace, key); + } + + @Override + public void put(List namespace, String key, Map value) { + writes.incrementAndGet(); + store.put(namespace, key, value); + } + + @Override + public List search(List namespace, int limit, int offset) { + return store.search(namespace, limit, offset); + } + + @Override + public void delete(List namespace, String key) { + store.delete(namespace, key); + } + }; + + assertThrows( + IllegalStateException.class, + () -> new LocalTeamClient(legacy).completeTeam("ns", "membership").block()); + + assertEquals(before, store.get(COMPLETION_META, "meta")); + assertEquals(0, writes.get()); + } + + private static void createCompletionTeam(LocalTeamClient client) { + client.createTeam( + new TeamCreateSpec("membership", "ns", "objective", "lead", "", List.of())) + .block(); + } + + private static TeamSessionMembership adoptCompletionTeam( + LocalTeamClient client, InMemoryAgentStateStore states) { + createCompletionTeam(client); + states.save("alice", "leader-session", "agent", new SavedState("original")); + TeamSessionMembership membership = client.sessionMembership("app", Map.of("state", states)); + membership + .adoptTeam( + "alice", + new TeamAddress("ns", "membership"), + "membership", + List.of( + new MemberSession( + "lead", + "alice", + "lead", + Role.LEADER, + new SessionKey("state", "alice", "leader-session")))) + .block(); + return membership; + } + + private static class CompletionFaultStore extends InMemoryStore { + boolean rejectCompletion; + int casAttempts; + int unconditionalWrites; + + @Override + public boolean putIfVersion( + List namespace, + String key, + Map value, + long expectedVersion) { + if (rejectCompletion && namespace.equals(COMPLETION_META) && key.equals("meta")) { + casAttempts++; + return false; + } + return super.putIfVersion(namespace, key, value, expectedVersion); + } + + @Override + public void put(List namespace, String key, Map value) { + if (rejectCompletion && namespace.equals(COMPLETION_META) && key.equals("meta")) { + unconditionalWrites++; + } + super.put(namespace, key, value); + } + } + + public record SavedState(String value) implements State {} } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index 2721e66af2..cac821396d 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.agentscope.core.state.AgentStateStore; import io.agentscope.core.state.InMemoryAgentStateStore; @@ -54,8 +55,53 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class TeamSessionMembershipTest { + // Frozen schema=1 shape written before Membership's public Source component was removed. + private static final String HISTORICAL_JSON = + """ + { + "meta": { + "objective": "historical objective", "phase": "Working", "leadRef": "leader-agent", + "sessionMembership": { + "schema": 1, "domain": "app", "owner": "alice", "teamId": "historical-team-id", + "displayName": "historical display", "registrationToken": "historical-registration", + "initial": [{ + "memberName": "lead", "definitionOwner": "definition-owner", "agentRef": "leader-agent", + "role": "LEADER", "session": { + "stateStoreDomain": "state", "owner": "alice", "sessionId": "leader-session" + } + }] + } + }, + "owner": { + "schema": 1, "receipts": {"historical-team-id": "historical-registration"}, + "memberships": [{ + "teamOwner": "alice", "teamId": "historical-team-id", + "address": {"namespace": "ns-a", "teamName": "a"}, + "member": { + "memberName": "lead", "definitionOwner": "definition-owner", "agentRef": "leader-agent", + "role": "LEADER", "session": { + "stateStoreDomain": "state", "owner": "alice", "sessionId": "leader-session" + } + }, + "source": "BYO", "token": "historical-leader-token" + }, { + "teamOwner": "alice", "teamId": "historical-team-id", + "address": {"namespace": "ns-a", "teamName": "a"}, + "member": { + "memberName": "worker", "definitionOwner": "definition-owner", "agentRef": "worker-agent", + "role": "WORKER", "session": { + "stateStoreDomain": "state", "owner": "alice", "sessionId": "worker-session" + } + }, + "source": "BYO", "token": "historical-worker-token" + }] + } + } + """; @TempDir Path temp; private InMemoryStore store; private InMemoryAgentStateStore states; @@ -74,6 +120,113 @@ void setUp() { create(b); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void historicalByoAndSourceOmittedRecordsKeepTokensAndRewriteCompatibleData( + boolean sourcePresent) throws Exception { + Map> historical = + historicalData( + sourcePresent + ? HISTORICAL_JSON + : HISTORICAL_JSON.replace("\"source\": \"BYO\",", "")); + List relationNs = List.of("team-session-membership", "YXBw"); + String ownerKey = "owner-YWxpY2U"; + store.put(List.of("teams", "ns-a", "a"), "meta", historical.get("meta")); + store.put(relationNs, ownerKey, historical.get("owner")); + member(states, "alice", "lead", "leader-session", "leader-agent"); + MemberSession worker = member(states, "alice", "worker", "worker-session", "worker-agent"); + Path workspace = temp.resolve("historical-workspace.txt"); + Files.writeString(workspace, "existing workspace"); + client = new LocalTeamClient(store); + service = client.sessionMembership("app", Map.of("state", states)); + Membership expected = + new Membership("alice", "historical-team-id", a, worker, "historical-worker-token"); + StoreItem original = store.get(relationNs, ownerKey); + String originalJson = new ObjectMapper().writeValueAsString(original.value()); + + TeamView view = service.getTeam("alice", a).block(); + assertEquals(Status.ACTIVE, view.status()); + assertEquals("historical-team-id", view.teamId()); + assertEquals("historical display", view.displayName()); + assertEquals("historical objective", view.info().objective()); + assertEquals(expected, service.findMembership(worker.session()).block()); + assertEquals(2, service.listMemberships("alice", a).block().size()); + assertEquals("worker-session", legacy("worker").sessionId()); + assertEquals(original.version(), store.get(relationNs, ownerKey).version()); + assertEquals( + originalJson, + new ObjectMapper().writeValueAsString(store.get(relationNs, ownerKey).value())); + + assertTrue(service.unbindMember("alice", a, worker.session(), expected.token()).block()); + assertEquals("", legacy("worker").sessionId()); + assertEquals(1, service.listMemberships("alice", a).block().size()); + Membership replacement = service.bindMember("alice", a, worker).block(); + assertNotEquals(expected.token(), replacement.token()); + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, worker.session(), expected.token()).block()); + assertEquals(replacement, service.findMembership(worker.session()).block()); + for (Object persisted : + (List) store.get(relationNs, ownerKey).value().get("memberships")) { + assertEquals("BYO", ((Map) persisted).get("source")); + } + assertEquals( + new SavedState("original"), + states.get("alice", "worker-session", "legacy", SavedState.class).orElseThrow()); + assertEquals("existing workspace", Files.readString(workspace)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "NULL_SOURCE", + "CREATED_SOURCE", + "FUTURE_SOURCE", + "UNKNOWN_MEMBER_FIELD", + "UNKNOWN_OWNER_FIELD", + "UNKNOWN_SCHEMA" + }) + void unknownHistoricalFormatsCannotBeQueriedOrUnboundAndAreNotRewritten(String format) + throws Exception { + String json = + switch (format) { + case "NULL_SOURCE" -> + HISTORICAL_JSON.replace("\"source\": \"BYO\"", "\"source\": null"); + case "CREATED_SOURCE" -> HISTORICAL_JSON.replace("\"BYO\"", "\"CREATED\""); + case "FUTURE_SOURCE" -> HISTORICAL_JSON.replace("\"BYO\"", "\"FUTURE\""); + case "UNKNOWN_MEMBER_FIELD" -> + HISTORICAL_JSON.replace( + "\"source\": \"BYO\"", + "\"source\": \"BYO\", \"futurePolicy\": true"); + default -> HISTORICAL_JSON; + }; + Map> historical = historicalData(json); + if (format.equals("UNKNOWN_OWNER_FIELD")) { + historical.get("owner").put("futurePolicy", true); + } else if (format.equals("UNKNOWN_SCHEMA")) { + historical.get("owner").put("schema", 99); + } + List relationNs = List.of("team-session-membership", "YXBw"); + String ownerKey = "owner-YWxpY2U"; + store.put(List.of("teams", "ns-a", "a"), "meta", historical.get("meta")); + store.put(relationNs, ownerKey, historical.get("owner")); + StoreItem original = store.get(relationNs, ownerKey); + String originalJson = new ObjectMapper().writeValueAsString(original.value()); + SessionKey session = new SessionKey("state", "alice", "worker-session"); + assertThrows(IllegalStateException.class, () -> service.getTeam("alice", a).block()); + assertThrows(IllegalStateException.class, () -> service.findMembership(session).block()); + assertThrows( + IllegalStateException.class, () -> service.listMemberships("alice", a).block()); + assertThrows(IllegalStateException.class, () -> client.listMembers("ns-a", "a").block()); + assertThrows( + IllegalStateException.class, + () -> service.unbindMember("alice", a, session, "historical-worker-token").block()); + assertEquals(original.version(), store.get(relationNs, ownerKey).version()); + assertEquals( + originalJson, + new ObjectMapper().writeValueAsString(store.get(relationNs, ownerKey).value())); + } + @Test void usableLifecycleKeepsLegacyMetadataStateAndWorkspace() throws Exception { JsonFileAgentStateStore files = new JsonFileAgentStateStore(temp.resolve("state")); @@ -429,6 +582,18 @@ void persistentCasConflictsDoNotMutateStoredReferencesOrFallBackToPut() throws E .writeValueAsString( faulty.search(List.of("team-session-membership"), 100, 0))); assertNull(service.findMembership(worker.session()).block()); + Membership bound = service.findMembership(lead.session()).block(); + faulty.failedCas.set(0); + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, lead.session(), bound.token()).block()); + assertEquals(10, faulty.failedCas.get()); + assertEquals(bound, service.findMembership(lead.session()).block()); + assertEquals( + before, + new ObjectMapper() + .writeValueAsString( + faulty.search(List.of("team-session-membership"), 100, 0))); assertThrows( UnsupportedOperationException.class, () -> service.listMemberships("alice", a).block().clear()); @@ -710,6 +875,10 @@ private FaultStore useFaultStore() { return faulty; } + private static Map> historicalData(String json) throws Exception { + return new ObjectMapper().readValue(json, new TypeReference<>() {}); + } + private void create(TeamAddress address) { client.createTeam( new TeamCreateSpec( diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index 16ebd1bcac..0f311101eb 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -114,7 +114,11 @@ Adoption then writes a marker to the existing Team meta and atomically commits i An identical bind retains its token. Unbinding requires the expected token and returns `true` when removed, or `false` when already absent. A wrong Team or stale token fails; an actual unbind followed by a new bind produces a new token. CAS contention retries at most ten times, then raises `TeamConflictException`; storage errors propagate without unconditional-write fallback. If the response fails after a commit, query or retry the identical request to determine the outcome. Cancellation does not roll back a committed association. -For adopted Teams, legacy `listMembers` reads the same associations and projects `sessionId`; an unbound declared member has an empty `sessionId`. The old record shape, declared `isLead`, `phase` and `deployMode` remain unchanged. Existing nonempty legacy Session fields require explicit full identities in the initial adoption request. `completeTeam` still changes only the original phase to `Completed`, preserving associations and the adoption marker. Versioned stores retry completion CAS rather than overwriting a concurrent update; persistent contention can now raise a conflict. Unenabled nonversioned stores retain their completion fallback. +For adopted Teams, legacy `listMembers` reads the same associations and projects `sessionId`; an unbound declared member has an empty `sessionId`. The old record shape, declared `isLead`, `phase` and `deployMode` remain unchanged. Existing nonempty legacy Session fields require explicit full identities in the initial adoption request. `completeTeam` still changes only the original phase to `Completed`, preserving associations and the adoption marker. A successful `sessionMembership(...)` call enables strict completion CAS on that client, including before adoption. Completion also uses strict CAS when a new client observes an adoption marker; persistent contention raises a conflict instead of overwriting another writer. Unenabled clients retain the old completion fallback for unadopted Teams, including on versioned stores. + +Before adopting a Team, stop its older-version and unenabled writers and wait for their in-flight writes to finish. Upgrade all participating writers and enable protection on each client with `sessionMembership(...)`. Enabling a client does not stop writes that already entered its legacy path. Older binaries do not understand the relationship projection, and their unconditional completion fallback can erase a concurrent adoption marker. Mixed-version writes to adopted Teams are not supported. For rollback, use a version that understands the stored format, or stop all writers and restore a backup taken before adoption. + +Relationship schema 1 reads earlier BYO records containing `source: "BYO"` and draft records that omitted the field. Missing source means manually supplied existing resources; it does not grant ownership for deletion. New relationship writes retain BYO in the internal storage format independently of the public `Membership` record. Unknown sources, fields or schema versions are rejected without resetting or overwriting the record. Team metadata, phase and declared definitions remain in their existing records. Only relationship links and adoption receipts are kept in one item per relationship domain and owner; read/write cost and contention grow with that owner's links. All writers to adopted records must honor the versioned capability; direct raw mutation or deletion is unsupported. Malformed or unknown relationship formats fail rather than being reset. Session existence validation and relationship commit are separate operations, so external Session deletion can leave an association that the application must explicitly remove. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index cb66f27ce0..359fecef19 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -113,7 +113,11 @@ boolean stateKept = stateStore.exists("alice", "worker-session"); 完全相同的绑定保留 token。解除需要预期 token;实际解除返回 `true`,已无关联返回 `false`。错误 Team 或过期 token 报错;真正解除后重绑会生成新 token。CAS 竞争最多重试十次,之后抛 `TeamConflictException`;存储错误直接传播,不降级为无条件写。提交后响应失败时,可查询或用相同请求重试确认结果;取消订阅不回滚已经提交的关系。 -已接入 Team 的旧 `listMembers` 读取同一关系并投影 `sessionId`,未关联的声明成员投影为空。旧 record 结构、声明的 `isLead`、`phase` 和 `deployMode` 不变。旧数据已有非空 Session 字段时,初始接入必须显式提供对应完整身份。`completeTeam` 仍只将原 phase 改为 `Completed`,保留关系及接入标记。版本存储改为重试完成 CAS,避免覆盖并发更新;持续竞争时可能报冲突。未启用的非版本存储保留旧完成 fallback。 +已接入 Team 的旧 `listMembers` 读取同一关系并投影 `sessionId`,未关联的声明成员投影为空。旧 record 结构、声明的 `isLead`、`phase` 和 `deployMode` 不变。旧数据已有非空 Session 字段时,初始接入必须显式提供对应完整身份。`completeTeam` 仍只将原 phase 改为 `Completed`,保留关系及接入标记。成功调用 `sessionMembership(...)` 后,当前 client 启用严格完成 CAS,包括尚未接入的 Team;新实例观察到接入标记时也使用严格 CAS。持续竞争时报冲突,避免覆盖其他写入方。未启用 client 对未接入 Team 保留旧完成 fallback,包括版本存储。 + +接入 Team 前,先停止该 Team 的旧版本及未启用写入方,等待其在途写操作结束,再升级所有参与写入的实例,并在每个 client 上调用 `sessionMembership(...)` 启用保护。启用 client 不会停止已经进入旧路径的写操作。旧二进制不理解归属投影,其无条件完成 fallback 可能清掉并发接入标记。已接入 Team 不支持新旧版本混写。回滚需使用理解现有存储格式的版本,或停止全部写入方后恢复接入前的备份。 + +关系 schema 1 可以读取早期包含 `source: "BYO"` 的记录,以及曾省略该字段的草案记录。缺省来源只表示手工提供的既有资源,不授予删除权。新关系写入在内部存储格式中保留 BYO,与公开 `Membership` record 分开。未知来源、字段或格式版本明确拒绝,不重置或覆盖原记录。 Team 元数据、phase 和声明定义继续使用原记录;每个关系域、每个 owner 的单条记录只保存关联和接入回执,读写成本与竞争随该 owner 的关联数增加。已接入记录的所有写入者须遵守版本能力;不支持直接原始修改或删除。损坏或未知关系格式会报错,不重置为空。Session 存在校验与关系提交是独立操作,外部删除 Session 可能留下关联,应用须显式解除。 From a24076e0b8838868ffbb372bd4e27c9b69665f7e Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 15:18:23 +0800 Subject: [PATCH 07/13] fix(harness): reject occupied sessions before team adoption Keep the legacy member directory and broadcasts available while adoption is pending. Share precise JDBC duplicate-key classification with Skill to preserve existing-file failures across supported diagnostics. --- .../extensions/jdbc/JdbcConstraintErrors.java | 65 +++++++++++ .../jdbc/skill/JdbcAgentSkillRepository.java | 101 +----------------- .../extensions/jdbc/store/JdbcStore.java | 25 +---- .../skill/JdbcAgentSkillRepositoryH2Test.java | 5 + .../JdbcAgentSkillRepositorySqliteTest.java | 10 ++ .../jdbc/store/JdbcStoreConstraintTest.java | 14 +++ .../store/TeamSessionMembershipH2Test.java | 6 +- .../agent/team/TeamSessionMembership.java | 22 ++-- .../agent/team/TeamSessionMembershipTest.java | 97 ++++++++++++++++- docs/v2/en/docs/harness/architecture.md | 4 +- docs/v2/zh/docs/harness/architecture.md | 4 +- 11 files changed, 217 insertions(+), 136 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/JdbcConstraintErrors.java diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/JdbcConstraintErrors.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/JdbcConstraintErrors.java new file mode 100644 index 0000000000..89f671d219 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/JdbcConstraintErrors.java @@ -0,0 +1,65 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.jdbc; + +import java.sql.SQLException; +import java.util.Set; + +/** Shared duplicate-key classification for JDBC stores and repositories. */ +public final class JdbcConstraintErrors { + + // SQLState 23000 covers all integrity constraints; the vendor code identifies duplicates. + // MySQL/MariaDB 1062 and 1022, Oracle ORA-00001, SQL Server 2601 and 2627. + private static final Set DUPLICATE_KEY_VENDOR_CODES = + Set.of(1062, 1022, 1, 2601, 2627); + // Informix/GBase 8s may report these duplicate codes without a SQLState. + private static final Set VENDOR_UNIQUE_VIOLATION_CODES = Set.of(-239, -268); + + private JdbcConstraintErrors() {} + + /** + * Recognizes specific unique/primary-key violations, never the general integrity category. + * + *

SQLState 23505 identifies unique violations in PostgreSQL, H2 and compatible drivers. + * SQLite's error 19 covers all constraints, so its optional driver is queried reflectively + * for the precise result code. Unknown diagnostics or unavailable result codes return false; + * callers must propagate those storage failures rather than treating them as contention. + * + * @param e the JDBC failure to classify + * @return true only for a positively identified duplicate-key conflict + */ + public static boolean isDuplicateKey(SQLException e) { + if ("23505".equals(e.getSQLState())) { + return true; + } + if ("23000".equals(e.getSQLState()) + && DUPLICATE_KEY_VENDOR_CODES.contains(e.getErrorCode())) { + return true; + } + if (e.getErrorCode() == 19 && e.getClass().getName().startsWith("org.sqlite.")) { + try { + Object result = e.getClass().getMethod("getResultCode").invoke(e); + if (result instanceof Enum code) { + return "SQLITE_CONSTRAINT_PRIMARYKEY".equals(code.name()) + || "SQLITE_CONSTRAINT_UNIQUE".equals(code.name()); + } + } catch (ReflectiveOperationException ignored) { + // An unknown constraint is not evidence of a duplicate key. + } + } + return VENDOR_UNIQUE_VIOLATION_CODES.contains(e.getErrorCode()); + } +} diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java index f39901784e..20293258c9 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java @@ -20,6 +20,7 @@ import io.agentscope.core.skill.repository.AgentSkillRepository; import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; import io.agentscope.core.util.JsonUtils; +import io.agentscope.extensions.jdbc.JdbcConstraintErrors; import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; import io.agentscope.extensions.jdbc.dialect.BoundSql; import io.agentscope.extensions.jdbc.dialect.table.SkillDialect; @@ -28,14 +29,12 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.sql.Statement; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.regex.Pattern; import javax.sql.DataSource; import org.slf4j.Logger; @@ -82,38 +81,6 @@ public class JdbcAgentSkillRepository implements AgentSkillRepository { */ private static final Pattern DRIVE_LETTER_PREFIX = Pattern.compile("^[A-Za-z]:.*"); - /** SQLite's {@code SQLITE_CONSTRAINT}; xerial reports it with a null SQLState. */ - private static final int SQLITE_CONSTRAINT = 19; - - /** - * Duplicate-key codes for drivers reporting no SQLState at all and not covered by {@link - * #SQLITE_CONSTRAINT}: Informix and GBase 8s — {@code -239} (duplicate value in a unique - * index) and {@code -268} (unique constraint violation). Values from the vendor - * documentation; those drivers were not available to verify against, unlike every other - * entry in {@link #isUniqueViolation}. - */ - private static final Set VENDOR_UNIQUE_VIOLATION_CODES = Set.of(-239, -268); - - /** - * SQLStates that positively report a unique-constraint violation: {@code 23505} (the - * SQL:2003 code — PostgreSQL and its forks, H2, HSQLDB, Derby, DB2). The umbrella {@code - * 23500} is deliberately absent — no driver reporting it for duplicates could be named, - * and umbrella states cover sibling violations; those stacks' duplicates reach {@link - * #DUPLICATE_KEY_VENDOR_CODES} under {@code 23000} instead. Should such a driver ever - * surface, the remedy is widening that gate to its class-23 state, not reinstating the - * umbrella. - */ - private static final Set UNIQUE_SQL_STATES = Set.of("23505"); - - /** - * Duplicate-key vendor codes under the generic integrity SQLState {@code 23000}, where - * the state alone cannot tell a duplicate from sibling violations: MySQL / MariaDB / - * TiDB / OceanBase {@code 1062}, MySQL 5.5-era {@code 1022}, Oracle {@code ORA-00001} - * = 1, SQL Server {@code 2601} / {@code 2627}. - */ - private static final Set DUPLICATE_KEY_VENDOR_CODES = - Set.of(1062, 1022, 1, 2601, 2627); - /** The data source holding the skill tables; never closed by this repository. */ private final DataSource dataSource; @@ -857,73 +824,13 @@ private static void bindParams(PreparedStatement ps, List params) throws } /** - * Whether the exception reports a duplicate-key / unique-constraint violation — the - * authoritative conflict signal for the {@code force=false} insert. Only positive - * duplicate signals match, so any other constraint failure surfaces as the driver's own - * {@link SQLException} instead of the destructive "use force=true" advice. - * - *

Signals, in order: - * - *

    - *
  1. SQLState in {@link #UNIQUE_SQL_STATES} — the portable unique-violation codes. - * pgjdbc and its forks never throw the JDBC 4 subtypes (issue #963 is still open), - * nor do Firebird, SQL Server, 达梦, or H2 1.x; all report 23505, as do H2 2.x, - * HSQLDB, Derby, and DB2 through the subtype. pgjdbc returns error code 0, so the - * code cannot stand in for the state. - *
  2. SQLState {@code 23000} plus a code from {@link #DUPLICATE_KEY_VENDOR_CODES} — - * that state is the whole class-23 umbrella for the MySQL family, Oracle, and SQL - * Server, so the vendor code decides. - *
  3. SQLite (xerial): error 19 with a null state is every {@code - * SQLITE_CONSTRAINT_*} collapsed; {@link #isSqliteUniqueViolation} narrows it. - *
  4. Vendor codes for drivers reporting no SQLState: {@link - * #VENDOR_UNIQUE_VIOLATION_CODES}. - *
- * - *

Deliberately not matched: the {@link SQLIntegrityConstraintViolationException} - * subtype on its own — MySQL Connector/J also raises it for NOT NULL (1048) and foreign - * key (1451/1452) violations, which is exactly the misreport this method must avoid — - * and the class-23 siblings (23502, 23503, 23514). An unmatched duplicate fails the - * save as a plain {@code SQLException} rather than a false conflict. - * - *

Also not matched: MySQL {@code ER_DUP_ENTRY_AUTOINCREMENT_CASE} (1569) reports - * SQLState {@code HY000} — outside class 23 — but it requires an explicit auto-increment - * value, which the skill insert never supplies. ClickHouse has no duplicate-key error to - * match: it does not enforce primary-key uniqueness. + * Uses the shared JDBC duplicate classifier; other constraints remain storage failures. * * @param e the exception thrown by the skill insert - * @return true when the failure is a duplicate-key conflict rather than any other error + * @return true only for a positively identified duplicate-key conflict */ static boolean isUniqueViolation(SQLException e) { - String state = e.getSQLState(); - // Null SQLState is real (SQLite reports one) and Set.of rejects null lookups. - if (state != null && UNIQUE_SQL_STATES.contains(state)) { - return true; - } - if ("23000".equals(state) && DUPLICATE_KEY_VENDOR_CODES.contains(e.getErrorCode())) { - return true; - } - if (e.getErrorCode() == SQLITE_CONSTRAINT - && e.getClass().getName().startsWith("org.sqlite.")) { - return isSqliteUniqueViolation(e); - } - return VENDOR_UNIQUE_VIOLATION_CODES.contains(e.getErrorCode()); - } - - /** - * Narrows SQLite's umbrella error 19 to unique violations via the driver's {@code - * getResultCode()}, reached reflectively to keep the driver off the compile classpath. - * Compares the enum's {@code name()}, not {@code toString()} — the latter renders the - * full message and never equals the bare constant (sqlite-jdbc 3.47.1.0). Falls back to - * the umbrella signal if reflection fails. - */ - private static boolean isSqliteUniqueViolation(SQLException e) { - try { - Object resultCode = e.getClass().getMethod("getResultCode").invoke(e); - return resultCode instanceof Enum - && "SQLITE_CONSTRAINT_UNIQUE".equals(((Enum) resultCode).name()); - } catch (ReflectiveOperationException reflectionFailure) { - return true; - } + return JdbcConstraintErrors.isDuplicateKey(e); } /** diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java index 535b52f768..8222770941 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.extensions.jdbc.JdbcConstraintErrors; import io.agentscope.extensions.jdbc.dialect.BoundSql; import io.agentscope.extensions.jdbc.dialect.table.StoreDialect; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; @@ -125,7 +126,7 @@ public boolean putIfVersion( ps.executeUpdate(); return true; } catch (SQLException e) { - if (isDuplicateKey(e)) { + if (JdbcConstraintErrors.isDuplicateKey(e)) { return false; } throw new IllegalStateException("JdbcStore putIfVersion (insert) failed", e); @@ -245,28 +246,6 @@ private static void validateKey(String key) { } } - private static boolean isDuplicateKey(SQLException e) { - if ("23505".equals(e.getSQLState())) { - return true; - } - if ("23000".equals(e.getSQLState()) && e.getErrorCode() == 1062) { - return true; - } - if (e.getErrorCode() == 19 && e.getClass().getName().startsWith("org.sqlite.")) { - // SQLite collapses all constraints into error 19. Keep its optional driver unlinked. - try { - Object result = e.getClass().getMethod("getResultCode").invoke(e); - if (result instanceof Enum code) { - return "SQLITE_CONSTRAINT_PRIMARYKEY".equals(code.name()) - || "SQLITE_CONSTRAINT_UNIQUE".equals(code.name()); - } - } catch (ReflectiveOperationException ignored) { - // An unknown constraint is a storage failure, not evidence of a duplicate key. - } - } - return false; - } - // ------------------------------------------------------------------------- // Builder // ------------------------------------------------------------------------- diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositoryH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositoryH2Test.java index ae4ccc0251..be8b94478a 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositoryH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositoryH2Test.java @@ -365,6 +365,11 @@ void narrowsToDuplicateSignals() { assertTrue( JdbcAgentSkillRepository.isUniqueViolation( new SQLException("d", "23000", 1062))); + for (int vendorCode : new int[] {1022, 1, 2601, 2627}) { + assertTrue( + JdbcAgentSkillRepository.isUniqueViolation( + new SQLException("d", "23000", vendorCode))); + } assertFalse( JdbcAgentSkillRepository.isUniqueViolation( new SQLException("d", "23000", 1048))); diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositorySqliteTest.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositorySqliteTest.java index 82719d72b1..d9aa48c9d3 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositorySqliteTest.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepositorySqliteTest.java @@ -32,6 +32,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.sqlite.SQLiteDataSource; +import org.sqlite.SQLiteErrorCode; +import org.sqlite.SQLiteException; /** * SQLite conflict-classification tests — the one vendor reporting every {@code @@ -69,6 +71,14 @@ void duplicateSaveSurfacesConflictAndRollsBack() { @Test @DisplayName("real driver errors classify: SQLITE_CONSTRAINT_UNIQUE matches, NOT NULL not") void realDriverErrorsClassify() throws Exception { + assertTrue( + JdbcAgentSkillRepository.isUniqueViolation( + new SQLiteException( + "primary key", SQLiteErrorCode.SQLITE_CONSTRAINT_PRIMARYKEY))); + assertFalse( + JdbcAgentSkillRepository.isUniqueViolation( + new SQLiteException( + "unknown constraint", SQLiteErrorCode.SQLITE_CONSTRAINT))); DataSource ds = createDataSource("skill_classifier"); skillDialect(ds); // build creates the tables the raw inserts target try (Connection conn = ds.getConnection(); diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java index 5386c5a409..046d6385bb 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/JdbcStoreConstraintTest.java @@ -29,6 +29,7 @@ import io.agentscope.extensions.jdbc.H2TestSupport; import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; import io.agentscope.extensions.jdbc.dialect.vendor.H2Dialect; +import io.agentscope.harness.agent.filesystem.remote.RemoteFilesystem; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; import java.nio.file.Path; import java.sql.Connection; @@ -126,6 +127,9 @@ void onlySpecificDuplicateSignalsReturnFalse(SQLException sqlError, boolean dupl JdbcStore store = JdbcStore.builder(ds).dialect(new H2Dialect()).build(); if (duplicate) { assertFalse(store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + var result = new RemoteFilesystem(store, ns).write(null, "file.txt", "content"); + assertFalse(result.isSuccess()); + assertTrue(result.error().contains("already exists")); } else { IllegalStateException failure = assertThrows( @@ -138,6 +142,16 @@ void onlySpecificDuplicateSignalsReturnFalse(SQLException sqlError, boolean dupl private static Stream sqlErrors() { return Stream.of( Arguments.of(new SQLException("duplicate", "23505", 0), true), + Arguments.of(new SQLException("MySQL old duplicate", "23000", 1022), true), + Arguments.of(new SQLException("Oracle duplicate", "23000", 1), true), + Arguments.of(new SQLException("SQL Server duplicate index", "23000", 2601), true), + Arguments.of( + new SQLException("SQL Server duplicate constraint", "23000", 2627), true), + Arguments.of(new SQLException("Informix duplicate", null, -239), true), + Arguments.of(new SQLException("Informix duplicate constraint", null, -268), true), + Arguments.of(new SQLException("Oracle check", "23000", 2290), false), + Arguments.of(new SQLException("SQL Server foreign key", "23000", 547), false), + Arguments.of(new SQLException("MySQL foreign key delete", "23000", 1451), false), Arguments.of( new SQLIntegrityConstraintViolationException("duplicate", "23000", 1062), true), diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java index 8173d71c87..bb71e0bf74 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -474,9 +474,13 @@ public boolean putIfVersion( Fixture reopened = open(); assertEquals(Status.PENDING, reopened.membership().getTeam("alice", a).block().status()); assertNull(reopened.membership().findMembership(lead.session()).block()); + assertEquals("", reopened.client().listMembers("ns-a", "a").block().get(0).sessionId()); + assertEquals( + 1, + reopened.client().broadcastMessage("ns-a", "a", "lead", "pending").block().size()); assertThrows( IllegalStateException.class, - () -> reopened.client().listMembers("ns-a", "a").block()); + () -> reopened.membership().listMemberships("alice", a).block()); assertEquals( Status.ACTIVE, reopened.membership().adoptTeam("alice", a, "a", List.of(lead)).block().status()); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index 05ff4ddf4e..cd2518c9ed 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -199,8 +199,9 @@ public record TeamView( /** * Adopts an existing Team using a complete initial request with exactly one declared Leader. * - *

The meta marker is written first; a single owner-item CAS commits all initial associations - * and the receipt. Failure between these writes leaves an observable PENDING adoption. Retry the + *

Known Session conflicts are rejected before the meta marker is written. A single + * owner-item CAS then commits all initial associations and the receipt. Failure between these + * writes leaves an observable PENDING adoption. Retry the * same request to finish it. A committed request replay returns current associations, without * restoring subsequently unbound members. Different adoption requests cannot replace the marker. * @@ -242,7 +243,7 @@ public Mono adoptTeam( break; } validateInitial(owner, address, initial); - prepareOwner(owner); + prepareOwner(owner, initial); Marker next = new Marker( 1, @@ -516,10 +517,16 @@ private OwnerItem readOwner(String owner) { return readOwner(store, domain, owner); } - private void prepareOwner(String owner) { - // Check actual storage before enabling projection from the Team's adoption marker. + private void prepareOwner(String owner, List initial) { + // Reject known conflicts and check storage before installing the Team's adoption marker. for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { OwnerItem current = readOwner(owner); + for (Membership existing : current.state().memberships()) { + if (initial.stream() + .anyMatch(m -> m.session().equals(existing.member().session()))) { + throw new TeamConflictException("Session is already associated"); + } + } if (current.version() > 0 || writeOwner( owner, @@ -633,7 +640,10 @@ static List projectMembers(BaseStore store, String namespace, St } TeamAddress address = new TeamAddress(namespace, teamName); OwnerState state = readOwner(store, marker.domain(), marker.owner()).state(); - active(committed(state, marker) ? Status.ACTIVE : Status.PENDING); + if (!committed(state, marker)) { + // Until commit, legacy readers retain the original directory, not new associations. + return declaredMembers(store, address); + } Map sessions = new HashMap<>(); members(state, marker, address) .forEach( diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index cac821396d..716837f75b 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -508,6 +508,11 @@ void ownerInitializationContentionDoesNotAdoptTeamOrBlockLegacyQueries() { void pendingAdoptionAfterFailureIsObservableAndRecoverableByNewClient() { FaultStore faulty = useFaultStore(); MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + StoreItem oldLead = store.get(List.of("teams", "ns-a", "a", "members"), "lead"); + Map oldValue = new LinkedHashMap<>(oldLead.value()); + oldValue.put("sessionId", "one"); + store.put(List.of("teams", "ns-a", "a", "members"), "lead", oldValue); + List declared = client.listMembers("ns-a", "a").block(); faulty.beforeRelation = true; assertThrows( IllegalStateException.class, @@ -515,7 +520,9 @@ void pendingAdoptionAfterFailureIsObservableAndRecoverableByNewClient() { assertEquals(Status.PENDING, service.getTeam("alice", a).block().status()); assertTrue(service.getTeam("alice", a).block().memberships().isEmpty()); assertNull(service.findMembership(lead.session()).block()); - assertThrows(IllegalStateException.class, () -> client.listMembers("ns-a", "a").block()); + assertEquals(declared, client.listMembers("ns-a", "a").block()); + assertEquals("one", legacy("lead").sessionId()); + assertEquals(1, client.broadcastMessage("ns-a", "a", "lead", "pending").block().size()); assertThrows( IllegalStateException.class, () -> service.listMemberships("alice", a).block()); faulty.beforeRelation = false; @@ -774,21 +781,101 @@ public StoreItem get(List namespace, String key) { } @Test - void conflictingPendingAdoptionCanFinishAfterPreviousTeamReleasesSession() { + void occupiedSessionRejectsAdoptionBeforeChangingTeamAndAllowsCorrectedRequest() { MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); Membership old = service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().get(0); + List metaNs = List.of("teams", "ns-b", "b"); + StoreItem before = store.get(metaNs, "meta"); + StoreItem relations = + store.get(List.of("team-session-membership", "YXBw"), "owner-YWxpY2U"); + List declared = client.listMembers("ns-b", "b").block(); + assertThrows( + TeamConflictException.class, + () -> service.adoptTeam("alice", b, "b", List.of(lead)).block()); + assertEquals(before, store.get(metaNs, "meta")); + assertEquals( + relations, store.get(List.of("team-session-membership", "YXBw"), "owner-YWxpY2U")); + assertEquals(declared, client.listMembers("ns-b", "b").block()); + assertEquals( + 1, client.broadcastMessage("ns-b", "b", "lead", "still available").block().size()); + MemberSession alternative = member(states, "alice", "lead", "two", "leader-agent"); + assertEquals( + Status.ACTIVE, + service.adoptTeam("alice", b, "b", List.of(alternative)).block().status()); + assertEquals(old, service.findMembership(lead.session()).block()); + assertTrue(states.exists("alice", "one")); + assertTrue(states.exists("alice", "two")); + } + + @Test + void occupiedInitialWorkerRejectsEntireAdoptionBeforeWritingMarker() { + MemberSession leadA = member(states, "alice", "lead", "leader-a", "leader-agent"); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + service.adoptTeam("alice", a, "a", List.of(leadA, worker)).block(); + MemberSession leadB = member(states, "alice", "lead", "leader-b", "leader-agent"); + StoreItem before = store.get(List.of("teams", "ns-b", "b"), "meta"); + assertThrows( + TeamConflictException.class, + () -> service.adoptTeam("alice", b, "b", List.of(leadB, worker)).block()); + assertEquals(before, store.get(List.of("teams", "ns-b", "b"), "meta")); + assertNull(service.findMembership(leadB.session()).block()); + assertEquals(2, service.listMemberships("alice", a).block().size()); + } + + @Test + void sessionClaimedAfterPreflightRemainsUniqueAndPendingTeamKeepsLegacyDirectory() { + AtomicBoolean claim = new AtomicBoolean(); + InMemoryStore racing = + new InMemoryStore() { + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + if (ns.equals(List.of("teams", "ns-b", "b")) + && value.containsKey("sessionMembership") + && claim.compareAndSet(false, true)) { + new LocalTeamClient(this) + .sessionMembership("app", Map.of("state", states)) + .adoptTeam( + "alice", + a, + "a", + List.of( + member( + states, + "alice", + "lead", + "one", + "leader-agent"))) + .block(); + } + return super.putIfVersion(ns, key, value, version); + } + }; + store = racing; + client = new LocalTeamClient(store); + create(a); + create(b); + service = client.sessionMembership("app", Map.of("state", states)); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + List declared = client.listMembers("ns-b", "b").block(); assertThrows( TeamConflictException.class, () -> service.adoptTeam("alice", b, "b", List.of(lead)).block()); + Membership winner = service.findMembership(lead.session()).block(); + assertEquals(a, winner.address()); assertEquals(Status.PENDING, service.getTeam("alice", b).block().status()); - service.unbindMember("alice", a, lead.session(), old.token()).block(); + assertEquals(declared, client.listMembers("ns-b", "b").block()); + assertEquals(1, client.broadcastMessage("ns-b", "b", "lead", "pending").block().size()); + assertThrows( + IllegalStateException.class, () -> service.listMemberships("alice", b).block()); + assertTrue(service.unbindMember("alice", a, lead.session(), winner.token()).block()); Membership replacement = service.adoptTeam("alice", b, "b", List.of(lead)).block().memberships().get(0); - assertNotEquals(old.token(), replacement.token()); + assertNotEquals(winner.token(), replacement.token()); assertThrows( TeamConflictException.class, - () -> service.unbindMember("alice", a, lead.session(), old.token()).block()); + () -> service.unbindMember("alice", a, lead.session(), winner.token()).block()); assertEquals(replacement, service.findMembership(lead.session()).block()); } diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index 0f311101eb..cbe0d316ae 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -108,9 +108,9 @@ The in-memory example demonstrates the operations without models or Worker execu `getTeam(owner, address)` returns the stable logical ID, display name, original `TeamInfo`, adoption status and current associations. `findMembership(session)` completes empty when unbound; `listMemberships` returns an immutable current list, including the Leader. These are associated Sessions, not the declared member directory or proof that a member is running. -Before writing an adoption marker, adoption ensures an empty owner item can be stored with CAS. A storage failure at this stage leaves the Team unadopted and legacy queries available; the empty item can be retained for a retry. Relationship addresses retain existing short encodings and use fixed-length SHA-256 encodings for long owners or domains, keeping within JDBC key and namespace limits. +Before writing an adoption marker, adoption checks that no initial Session is already associated and ensures an empty owner item can be stored with CAS. An existing association rejects the request without changing Team metadata or bindings; a corrected request can use another Session. A storage failure at this stage leaves the Team unadopted and legacy queries available; the empty item can be retained for a retry. Relationship addresses retain existing short encodings and use fixed-length SHA-256 encodings for long owners or domains, keeping within JDBC key and namespace limits. -Adoption then writes a marker to the existing Team meta and atomically commits initial associations and a receipt in one owner item. A failure between these writes leaves `PENDING`: `getTeam` reports it with no committed members, and both `listMemberships` and legacy `listMembers` fail explicitly. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. +Adoption then writes a marker to the existing Team meta and atomically commits initial associations and a receipt in one owner item. A failure or a concurrent Session claim between these writes can leave `PENDING`: `getTeam` reports it with no committed members, and `listMemberships` fails explicitly. Legacy `listMembers` and `broadcastMessage` continue using the original declared directory, including its original Session fields; this does not report committed associations. After commit, `listMembers` switches to the association projection. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. An identical bind retains its token. Unbinding requires the expected token and returns `true` when removed, or `false` when already absent. A wrong Team or stale token fails; an actual unbind followed by a new bind produces a new token. CAS contention retries at most ten times, then raises `TeamConflictException`; storage errors propagate without unconditional-write fallback. If the response fails after a commit, query or retry the identical request to determine the outcome. Cancellation does not roll back a committed association. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index 359fecef19..a223cbd352 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -107,9 +107,9 @@ boolean stateKept = stateStore.exists("alice", "worker-session"); `getTeam(owner, address)` 返回稳定逻辑 ID、展示名、原有 `TeamInfo`、接入状态和当前关系。`findMembership(session)` 在未关联时以 empty 完成;`listMemberships` 返回含 Leader 的不可变当前列表。这些是已关联的 Session,与声明成员目录不同,也不证明成员正在运行。 -写接入标记前,接入操作先通过 CAS 确保空 owner 记录可以存储。此阶段存储失败时 Team 尚未接入,旧查询仍可用;空记录可以保留供重试。关系地址保留已有短编码,过长 owner 或关系域改用定长 SHA-256 编码,满足 JDBC 键及 namespace 的长度限制。 +写接入标记前,接入操作检查全部初始 Session 尚未关联,并通过 CAS 确保空 owner 记录可以存储。已有关联会直接拒绝,不修改 Team 元数据或关系;可以换用其他 Session 修正请求。此阶段存储失败时 Team 尚未接入,旧查询仍可用;空记录可以保留供重试。关系地址保留已有短编码,过长 owner 或关系域改用定长 SHA-256 编码,满足 JDBC 键及 namespace 的长度限制。 -随后在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。这两次写入中间失败会留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 及旧 `listMembers` 明确报错。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 +随后在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。这两次写入中间发生故障或 Session 被并发抢占,仍可能留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 明确报错。旧 `listMembers` 及 `broadcastMessage` 继续使用原声明目录,包括原 Session 字段;这不代表新归属已经提交。提交后 `listMembers` 才切换到关系投影。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 完全相同的绑定保留 token。解除需要预期 token;实际解除返回 `true`,已无关联返回 `false`。错误 Team 或过期 token 报错;真正解除后重绑会生成新 token。CAS 竞争最多重试十次,之后抛 `TeamConflictException`;存储错误直接传播,不降级为无条件写。提交后响应失败时,可查询或用相同请求重试确认结果;取消订阅不回滚已经提交的关系。 From 10c0cfc63b4b6addaebb166c6d4d14cb40c6962c Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 19:48:23 +0800 Subject: [PATCH 08/13] perf(harness): reuse initial team membership snapshots --- .../harness/agent/team/LocalTeamClient.java | 5 +- .../agent/team/TeamSessionMembership.java | 29 +++-- .../agent/team/LocalTeamClientTest.java | 93 +++++++++++++- .../agent/team/TeamSessionMembershipTest.java | 115 ++++++++++++++++++ 4 files changed, 227 insertions(+), 15 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java index 1ac56f220a..7d00bc78e5 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java @@ -579,7 +579,10 @@ public Mono completeTeam(String namespace, String teamName) { // A stale unconditional fallback could erase concurrent adoption. for (int attempt = 0; attempt < 10; attempt++) { StoreItem current = - store.get(teamNs(namespace, teamName), "meta"); + attempt == 0 + ? item + : store.get( + teamNs(namespace, teamName), "meta"); if (current != null && current.version() <= 0) { throw new IllegalStateException( "Store must return positive item versions"); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index cd2518c9ed..2576a8b8ce 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -237,8 +237,9 @@ public Mono adoptTeam( "Team has a different adoption request"); } marker = existing; - if (committed(readOwner(owner).state(), marker)) { - return view(address, meta, marker); + OwnerState state = readOwner(owner).state(); + if (committed(state, marker)) { + return view(address, meta, marker, state); } break; } @@ -300,7 +301,8 @@ public Mono adoptTeam( public Mono bindMember(String owner, TeamAddress address, MemberSession member) { return io( () -> { - Marker marker = requireActive(owner, address); + ActiveTeam active = requireActive(owner, address); + Marker marker = active.marker(); validateMember(owner, address, member); Membership proposed = new Membership( @@ -310,7 +312,7 @@ public Mono bindMember(String owner, TeamAddress address, MemberSess member, UUID.randomUUID().toString()); for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - OwnerItem current = readOwner(owner); + OwnerItem current = attempt == 0 ? active.ownerItem() : readOwner(owner); if (!committed(current.state(), marker)) { throw corrupt(); } @@ -380,9 +382,10 @@ public Mono unbindMember( () -> { sameOwner(owner, session); text(expectedToken, "expected binding token"); - Marker marker = requireActive(owner, address); + ActiveTeam active = requireActive(owner, address); + Marker marker = active.marker(); for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - OwnerItem current = readOwner(owner); + OwnerItem current = attempt == 0 ? active.ownerItem() : readOwner(owner); List next = new ArrayList<>(current.state().memberships()); Membership bound = next.stream() @@ -413,11 +416,10 @@ private TeamView get(String owner, TeamAddress address) { throw new IllegalArgumentException("Team has not been adopted"); } requireScope(marker, owner); - return view(address, meta, marker); + return view(address, meta, marker, readOwner(owner).state()); } - private TeamView view(TeamAddress address, StoreItem meta, Marker marker) { - OwnerState state = readOwner(marker.owner()).state(); + private TeamView view(TeamAddress address, StoreItem meta, Marker marker, OwnerState state) { boolean committed = committed(state, marker); TeamInfo info = new TeamInfo( @@ -436,14 +438,15 @@ private TeamView view(TeamAddress address, StoreItem meta, Marker marker) { committed ? members(state, marker, address) : List.of()); } - private Marker requireActive(String owner, TeamAddress address) { + private ActiveTeam requireActive(String owner, TeamAddress address) { Marker marker = marker(requireMeta(address)); if (marker == null) { throw new IllegalArgumentException("Team has not been adopted"); } requireScope(marker, owner); - active(committed(readOwner(owner).state(), marker) ? Status.ACTIVE : Status.PENDING); - return marker; + OwnerItem current = readOwner(owner); + active(committed(current.state(), marker) ? Status.ACTIVE : Status.PENDING); + return new ActiveTeam(marker, current); } private void requireScope(Marker marker, String owner) { @@ -722,6 +725,8 @@ private record OwnerState( private record OwnerItem(long version, OwnerState state) {} + private record ActiveTeam(Marker marker, OwnerItem ownerItem) {} + // Schema 1 keeps BYO on disk independently of the public Membership shape. private record StoredOwnerState( int schema, Map receipts, List memberships) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java index bfc4e4735e..a97c981fb7 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/LocalTeamClientTest.java @@ -33,6 +33,7 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -240,6 +241,76 @@ void concurrentSelfClaim_onlyOneWins() throws Exception { assertTrue(client.listClaimableTasks("ns", "race").block().isEmpty()); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void strictCompletionUsesOneMetaSnapshotWithoutContention(boolean reopen) { + CompletionFaultStore store = new CompletionFaultStore(); + LocalTeamClient original = new LocalTeamClient(store); + TeamSessionMembership membership = + adoptCompletionTeam(original, new InMemoryAgentStateStore()); + SessionKey session = new SessionKey("state", "alice", "leader-session"); + Membership before = membership.findMembership(session).block(); + LocalTeamClient completing = reopen ? new LocalTeamClient(store) : original; + store.metaReads.set(0); + + completing.completeTeam("ns", "membership").block(); + + assertEquals(1, store.metaReads.get()); + assertEquals("Completed", store.get(COMPLETION_META, "meta").value().get("phase")); + assertEquals(before, membership.findMembership(session).block()); + store.metaReads.set(0); + completing.completeTeam("ns", "membership").block(); + assertEquals(1, store.metaReads.get()); + assertEquals(1, store.casAttempts); + } + + @Test + void strictCompletionRereadsAfterConcurrentAdoptionAndPreservesMarker() { + CompletionFaultStore store = new CompletionFaultStore(); + LocalTeamClient completing = new LocalTeamClient(store); + createCompletionTeam(completing); + InMemoryAgentStateStore states = new InMemoryAgentStateStore(); + states.save("alice", "leader-session", "agent", new SavedState("original")); + TeamSessionMembership membership = + completing.sessionMembership("app", Map.of("state", states)); + MemberSession leader = + new MemberSession( + "lead", + "alice", + "lead", + Role.LEADER, + new SessionKey("state", "alice", "leader-session")); + AtomicInteger readsBeforeConflict = new AtomicInteger(); + AtomicReference adopted = new AtomicReference<>(); + store.beforeCompletionCas.set( + () -> { + readsBeforeConflict.set(store.metaReads.get()); + adopted.set( + new LocalTeamClient(store) + .sessionMembership("app", Map.of("state", states)) + .adoptTeam( + "alice", + new TeamAddress("ns", "membership"), + "membership", + List.of(leader)) + .block() + .memberships() + .get(0)); + }); + store.metaReads.set(0); + + completing.completeTeam("ns", "membership").block(); + + assertEquals(1, readsBeforeConflict.get()); + assertEquals(2, store.casAttempts); + assertEquals("Completed", store.get(COMPLETION_META, "meta").value().get("phase")); + assertEquals(adopted.get(), membership.findMembership(leader.session()).block()); + assertEquals( + adopted.get().teamId(), + membership.getTeam("alice", new TeamAddress("ns", "membership")).block().teamId()); + assertEquals(0, store.unconditionalWrites); + } + @Test void completionWithoutMembershipOptInRetainsVersionedStoreFallback() { CompletionFaultStore store = new CompletionFaultStore(); @@ -378,6 +449,16 @@ private static class CompletionFaultStore extends InMemoryStore { boolean rejectCompletion; int casAttempts; int unconditionalWrites; + final AtomicInteger metaReads = new AtomicInteger(); + final AtomicReference beforeCompletionCas = new AtomicReference<>(); + + @Override + public StoreItem get(List namespace, String key) { + if (namespace.equals(COMPLETION_META) && key.equals("meta")) { + metaReads.incrementAndGet(); + } + return super.get(namespace, key); + } @Override public boolean putIfVersion( @@ -385,9 +466,17 @@ public boolean putIfVersion( String key, Map value, long expectedVersion) { - if (rejectCompletion && namespace.equals(COMPLETION_META) && key.equals("meta")) { + if (namespace.equals(COMPLETION_META) + && key.equals("meta") + && "Completed".equals(value.get("phase"))) { casAttempts++; - return false; + Runnable interleave = beforeCompletionCas.getAndSet(null); + if (interleave != null) { + interleave.run(); + } + if (rejectCompletion) { + return false; + } } return super.putIfVersion(namespace, key, value, expectedVersion); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index 716837f75b..9e7dcbb653 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -52,6 +52,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -484,6 +485,85 @@ void longOwnerAndDomainAddressesKeepDistinctSessionScopes() { assertEquals(y, right.findMembership(first.session()).block()); } + @Test + void uncontendedOperationsReuseOneOwnerSnapshot() { + CountingStore counting = useCountingStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + Membership leader = + service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().get(0); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + + counting.ownerReads.set(0); + Membership binding = service.bindMember("alice", a, worker).block(); + assertEquals(1, counting.ownerReads.get()); + counting.ownerReads.set(0); + assertEquals(binding, service.bindMember("alice", a, worker).block()); + assertEquals(1, counting.ownerReads.get()); + counting.ownerReads.set(0); + assertEquals( + 2, service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().size()); + assertEquals(1, counting.ownerReads.get()); + counting.ownerReads.set(0); + assertTrue(service.unbindMember("alice", a, worker.session(), binding.token()).block()); + assertEquals(1, counting.ownerReads.get()); + counting.ownerReads.set(0); + assertFalse(service.unbindMember("alice", a, worker.session(), binding.token()).block()); + assertEquals(1, counting.ownerReads.get()); + counting.ownerReads.set(0); + assertTrue(service.unbindMember("alice", a, lead.session(), leader.token()).block()); + assertEquals(1, counting.ownerReads.get()); + counting.ownerReads.set(0); + assertTrue( + service.adoptTeam("alice", a, "a", List.of(lead)).block().memberships().isEmpty()); + assertEquals(1, counting.ownerReads.get()); + } + + @Test + void bindRereadsAfterConflictAndDoesNotOverwriteAnotherTeam() { + CountingStore counting = useCountingStore(); + MemberSession leadA = member(states, "alice", "lead", "leader-a", "leader-agent"); + MemberSession leadB = member(states, "alice", "lead", "leader-b", "leader-agent"); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + service.adoptTeam("alice", a, "a", List.of(leadA)).block(); + TeamSessionMembership other = + new LocalTeamClient(counting).sessionMembership("app", Map.of("state", states)); + other.adoptTeam("alice", b, "b", List.of(leadB)).block(); + counting.beforeOwnerCas.set(() -> other.bindMember("alice", b, worker).block()); + + assertThrows( + TeamConflictException.class, () -> service.bindMember("alice", a, worker).block()); + + assertEquals(b, service.findMembership(worker.session()).block().address()); + assertEquals(1, service.listMemberships("alice", a).block().size()); + assertEquals(2, other.listMemberships("alice", b).block().size()); + } + + @Test + void unbindRereadsAfterConflictAndPreservesReplacementBinding() { + CountingStore counting = useCountingStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + Membership original = service.bindMember("alice", a, worker).block(); + TeamSessionMembership other = + new LocalTeamClient(counting).sessionMembership("app", Map.of("state", states)); + AtomicReference replacement = new AtomicReference<>(); + counting.beforeOwnerCas.set( + () -> { + assertTrue( + other.unbindMember("alice", a, worker.session(), original.token()) + .block()); + replacement.set(other.bindMember("alice", a, worker).block()); + }); + + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, worker.session(), original.token()).block()); + + assertNotEquals(original.token(), replacement.get().token()); + assertEquals(replacement.get(), service.findMembership(worker.session()).block()); + } + @Test void ownerInitializationContentionDoesNotAdoptTeamOrBlockLegacyQueries() { FaultStore faulty = useFaultStore(); @@ -952,6 +1032,16 @@ void malformedRelationshipItemDoesNotBecomeAnEmptyWritableRegistry() { assertEquals(version, store.get(ns, key).version()); } + private CountingStore useCountingStore() { + CountingStore counting = new CountingStore(); + store = counting; + client = new LocalTeamClient(store); + service = client.sessionMembership("app", Map.of("state", states)); + create(a); + create(b); + return counting; + } + private FaultStore useFaultStore() { FaultStore faulty = new FaultStore(); store = faulty; @@ -1041,6 +1131,31 @@ private static Map strip(Map value) { return out; } + private static class CountingStore extends InMemoryStore { + final AtomicInteger ownerReads = new AtomicInteger(); + final AtomicReference beforeOwnerCas = new AtomicReference<>(); + + @Override + public StoreItem get(List ns, String key) { + if (ns.get(0).equals("team-session-membership")) { + ownerReads.incrementAndGet(); + } + return super.get(ns, key); + } + + @Override + public boolean putIfVersion( + List ns, String key, Map value, long version) { + if (ns.get(0).equals("team-session-membership")) { + Runnable interleave = beforeOwnerCas.getAndSet(null); + if (interleave != null) { + interleave.run(); + } + } + return super.putIfVersion(ns, key, value, version); + } + } + private static class FaultStore extends InMemoryStore { boolean beforeMeta, beforeRelation, afterRelation, conflict; final AtomicInteger failedCas = new AtomicInteger(); From dfab6f41dd6f1aff5e3276d3066d4b748a603b7c Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Tue, 6 Oct 2026 20:52:44 +0800 Subject: [PATCH 09/13] fix(harness): replay concurrent team adoption after preflight --- .../agent/team/TeamSessionMembership.java | 16 ++++-- .../agent/team/TeamSessionMembershipTest.java | 57 ++++++++++++++++++- docs/v2/en/docs/harness/architecture.md | 2 +- docs/v2/zh/docs/harness/architecture.md | 2 +- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index 2576a8b8ce..bf209277e8 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -244,7 +244,13 @@ public Mono adoptTeam( break; } validateInitial(owner, address, initial); - prepareOwner(owner, initial); + if (!prepareOwner(owner, initial)) { + // A matching adoption may have committed since the first meta read. + if (marker(requireMeta(address)) != null) { + continue; + } + throw new TeamConflictException("Session is already associated"); + } Marker next = new Marker( 1, @@ -520,14 +526,14 @@ private OwnerItem readOwner(String owner) { return readOwner(store, domain, owner); } - private void prepareOwner(String owner, List initial) { - // Reject known conflicts and check storage before installing the Team's adoption marker. + private boolean prepareOwner(String owner, List initial) { + // Check Session availability and storage before installing the Team's adoption marker. for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { OwnerItem current = readOwner(owner); for (Membership existing : current.state().memberships()) { if (initial.stream() .anyMatch(m -> m.session().equals(existing.member().session()))) { - throw new TeamConflictException("Session is already associated"); + return false; } } if (current.version() > 0 @@ -536,7 +542,7 @@ private void prepareOwner(String owner, List initial) { current, current.state().receipts(), current.state().memberships())) { - return; + return true; } } throw contention(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index 9e7dcbb653..c5b60daa12 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -959,6 +959,60 @@ public boolean putIfVersion( assertEquals(replacement, service.findMembership(lead.session()).block()); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void adoptionRechecksConcurrentTeamMarkerBeforeRejectingOccupiedSession(boolean identical) { + AtomicReference afterMetaRead = new AtomicReference<>(); + InMemoryStore interleaved = + new InMemoryStore() { + @Override + public StoreItem get(List ns, String key) { + StoreItem item = super.get(ns, key); + if (ns.equals(List.of("teams", "ns-a", "a")) && key.equals("meta")) { + Runnable concurrentAdoption = afterMetaRead.getAndSet(null); + if (concurrentAdoption != null) { + concurrentAdoption.run(); + } + } + return item; + } + }; + store = interleaved; + client = new LocalTeamClient(store); + create(a); + service = client.sessionMembership("app", Map.of("state", states)); + TeamSessionMembership other = + new LocalTeamClient(store).sessionMembership("app", Map.of("state", states)); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + AtomicReference committed = new AtomicReference<>(); + AtomicReference committedMeta = new AtomicReference<>(); + afterMetaRead.set( + () -> { + committed.set(other.adoptTeam("alice", a, "a", List.of(lead)).block()); + committedMeta.set(store.get(List.of("teams", "ns-a", "a"), "meta")); + }); + + if (identical) { + TeamView replayed = service.adoptTeam("alice", a, "a", List.of(lead)).block(); + assertEquals(committed.get(), replayed); + } else { + TeamConflictException conflict = + assertThrows( + TeamConflictException.class, + () -> + service.adoptTeam("alice", a, "different", List.of(lead)) + .block()); + assertEquals("Team has a different adoption request", conflict.getMessage()); + } + + assertEquals(committed.get(), service.getTeam("alice", a).block()); + assertEquals(committedMeta.get(), store.get(List.of("teams", "ns-a", "a"), "meta")); + assertEquals( + committed.get().memberships().get(0), + service.findMembership(lead.session()).block()); + assertEquals(1, service.listMemberships("alice", a).block().size()); + } + @Test void concurrentSameAdoptionHasOneLogicalIdAndOnePersistentBinding() throws Exception { RacingStore racing = new RacingStore(); @@ -975,7 +1029,8 @@ void concurrentSameAdoptionHasOneLogicalIdAndOnePersistentBinding() throws Excep CompletableFuture second = attempt(() -> other.adoptTeam("alice", a, "a", List.of(lead)).block()); Object x = first.get(10, TimeUnit.SECONDS), y = second.get(10, TimeUnit.SECONDS); - assertTrue(x instanceof TeamView); + assertTrue(x instanceof TeamView, () -> "First adoption result: " + x); + assertTrue(y instanceof TeamView, () -> "Second adoption result: " + y); assertEquals(x, y); assertEquals(1, service.listMemberships("alice", a).block().size()); } diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index cbe0d316ae..bac6718b9a 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -108,7 +108,7 @@ The in-memory example demonstrates the operations without models or Worker execu `getTeam(owner, address)` returns the stable logical ID, display name, original `TeamInfo`, adoption status and current associations. `findMembership(session)` completes empty when unbound; `listMemberships` returns an immutable current list, including the Leader. These are associated Sessions, not the declared member directory or proof that a member is running. -Before writing an adoption marker, adoption checks that no initial Session is already associated and ensures an empty owner item can be stored with CAS. An existing association rejects the request without changing Team metadata or bindings; a corrected request can use another Session. A storage failure at this stage leaves the Team unadopted and legacy queries available; the empty item can be retained for a retry. Relationship addresses retain existing short encodings and use fixed-length SHA-256 encodings for long owners or domains, keeping within JDBC key and namespace limits. +Before writing an adoption marker, adoption checks that no initial Session is already associated and ensures an empty owner item can be stored with CAS. An initial Session associated with another Team rejects the request without changing Team metadata or bindings; a corrected request can use another Session. Concurrent identical adoption requests reuse the same logical Team ID and committed bindings, subject to the CAS retry limit below. A storage failure at this stage leaves the Team unadopted and legacy queries available; the empty item can be retained for a retry. Relationship addresses retain existing short encodings and use fixed-length SHA-256 encodings for long owners or domains, keeping within JDBC key and namespace limits. Adoption then writes a marker to the existing Team meta and atomically commits initial associations and a receipt in one owner item. A failure or a concurrent Session claim between these writes can leave `PENDING`: `getTeam` reports it with no committed members, and `listMemberships` fails explicitly. Legacy `listMembers` and `broadcastMessage` continue using the original declared directory, including its original Session fields; this does not report committed associations. After commit, `listMembers` switches to the association projection. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index a223cbd352..4dab786250 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -107,7 +107,7 @@ boolean stateKept = stateStore.exists("alice", "worker-session"); `getTeam(owner, address)` 返回稳定逻辑 ID、展示名、原有 `TeamInfo`、接入状态和当前关系。`findMembership(session)` 在未关联时以 empty 完成;`listMemberships` 返回含 Leader 的不可变当前列表。这些是已关联的 Session,与声明成员目录不同,也不证明成员正在运行。 -写接入标记前,接入操作检查全部初始 Session 尚未关联,并通过 CAS 确保空 owner 记录可以存储。已有关联会直接拒绝,不修改 Team 元数据或关系;可以换用其他 Session 修正请求。此阶段存储失败时 Team 尚未接入,旧查询仍可用;空记录可以保留供重试。关系地址保留已有短编码,过长 owner 或关系域改用定长 SHA-256 编码,满足 JDBC 键及 namespace 的长度限制。 +写接入标记前,接入操作检查全部初始 Session 尚未关联,并通过 CAS 确保空 owner 记录可以存储。初始 Session 已关联其他 Team 时会直接拒绝,不修改 Team 元数据或关系;可以换用其他 Session 修正请求。并发的相同接入请求复用同一个逻辑 Team ID 和已提交的绑定,仍受下述 CAS 重试上限约束。此阶段存储失败时 Team 尚未接入,旧查询仍可用;空记录可以保留供重试。关系地址保留已有短编码,过长 owner 或关系域改用定长 SHA-256 编码,满足 JDBC 键及 namespace 的长度限制。 随后在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。这两次写入中间发生故障或 Session 被并发抢占,仍可能留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 明确报错。旧 `listMembers` 及 `broadcastMessage` 继续使用原声明目录,包括原 Session 字段;这不代表新归属已经提交。提交后 `listMembers` 才切换到关系投影。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 From 863cccafd1d08a75aa22dbbc42500a37ff91be94 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Wed, 21 Oct 2026 00:37:03 +0800 Subject: [PATCH 10/13] refactor(harness): reuse BaseStore atomic capability --- .../extensions/jdbc/store/JdbcStore.java | 3 +- .../store/TeamSessionMembershipH2Test.java | 9 +- .../extensions/mysql/store/JdbcStore.java | 40 ++--- .../mysql/store/JdbcStoreConstraintTest.java | 162 ++++++++++++++++++ .../postgresql/store/PostgresBaseStore.java | 7 +- .../store/PostgresBaseStoreTest.java | 23 ++- .../filesystem/remote/store/BaseStore.java | 12 +- .../remote/store/InMemoryStore.java | 2 +- .../remote/store/VersionedBaseStore.java | 44 ----- .../harness/agent/team/LocalTeamClient.java | 5 +- .../agent/team/TeamSessionMembership.java | 14 +- .../agent/team/TeamSessionMembershipTest.java | 101 +++++++++++ docs/v2/en/docs/harness/architecture.md | 2 +- docs/v2/zh/docs/harness/architecture.md | 2 +- 14 files changed, 335 insertions(+), 91 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-mysql/src/test/java/io/agentscope/extensions/mysql/store/JdbcStoreConstraintTest.java delete mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java index 23721ad926..baea27541e 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/store/JdbcStore.java @@ -22,7 +22,6 @@ import io.agentscope.extensions.jdbc.dialect.table.StoreDialect; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; -import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -45,7 +44,7 @@ * * @author shanhongyu */ -public class JdbcStore implements VersionedBaseStore { +public class JdbcStore implements BaseStore { private static final Logger LOG = LoggerFactory.getLogger(JdbcStore.class); diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java index bb71e0bf74..96b319d19f 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -28,8 +28,8 @@ import io.agentscope.core.state.State; import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; import io.agentscope.extensions.jdbc.state.JdbcAgentStateStore; +import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; -import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import io.agentscope.harness.agent.team.LocalTeamClient; import io.agentscope.harness.agent.team.TeamConflictException; import io.agentscope.harness.agent.team.TeamCreateSpec; @@ -595,13 +595,18 @@ private record Fixture( LocalTeamClient client, TeamSessionMembership membership) {} - private static class DelegatingStore implements VersionedBaseStore { + private static class DelegatingStore implements BaseStore { private final JdbcStore delegate; DelegatingStore(JdbcStore delegate) { this.delegate = delegate; } + @Override + public boolean supportsAtomicSessionStorage() { + return delegate.supportsAtomicSessionStorage(); + } + @Override public StoreItem get(List ns, String key) { return delegate.get(ns, key); diff --git a/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java b/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java index 554164f2bb..31fe145849 100644 --- a/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java +++ b/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java @@ -24,7 +24,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; @@ -200,8 +199,6 @@ public boolean putIfVersion( ps.setLong(4, now); ps.executeUpdate(); return true; - } catch (SQLIntegrityConstraintViolationException dup) { - return false; } catch (SQLException e) { if (isDuplicateKey(e)) { return false; @@ -326,30 +323,29 @@ private static void validateKey(String key) { } } - /** - * Returns {@code true} if the given exception represents a primary-key / unique-constraint - * violation. Different JDBC drivers signal this differently: - * - *
    - *
  • MySQL / MariaDB / Postgres / H2 throw {@link SQLIntegrityConstraintViolationException} - * and/or set SQLSTATE {@code 23xxx}. - *
  • SQLite throws a plain {@code SQLiteException} with {@code sqlState=null} and - * {@code errorCode=19} ({@code SQLITE_CONSTRAINT}); we also key on the driver class name - * to avoid swallowing unrelated vendor codes that happen to be 19. - *
- */ + /** Recognizes duplicate keys for the supported JDBC dialects, not other constraint failures. */ private static boolean isDuplicateKey(SQLException e) { - if (e instanceof SQLIntegrityConstraintViolationException) { + if ("23505".equals(e.getSQLState())) { return true; } - String state = e.getSQLState(); - if (state != null && state.startsWith("23")) { - // 23xxx = integrity constraint violation in SQL:2003 + if ("23000".equals(e.getSQLState()) + && (e.getErrorCode() == 1062 || e.getErrorCode() == 1022)) { return true; } - // SQLite reports constraint violations as errorCode=19 with a null SQLSTATE. - String className = e.getClass().getName(); - return e.getErrorCode() == 19 && className.startsWith("org.sqlite."); + // SQLite is optional; distinguish unique violations without requiring the driver at + // runtime. + if (e.getErrorCode() == 19 && e.getClass().getName().startsWith("org.sqlite.")) { + try { + Object result = e.getClass().getMethod("getResultCode").invoke(e); + if (result instanceof Enum code) { + return "SQLITE_CONSTRAINT_PRIMARYKEY".equals(code.name()) + || "SQLITE_CONSTRAINT_UNIQUE".equals(code.name()); + } + } catch (ReflectiveOperationException ignored) { + // Unknown constraints must propagate rather than being treated as contention. + } + } + return false; } // ------------------------------------------------------------------------- diff --git a/agentscope-extensions/agentscope-extensions-mysql/src/test/java/io/agentscope/extensions/mysql/store/JdbcStoreConstraintTest.java b/agentscope-extensions/agentscope-extensions-mysql/src/test/java/io/agentscope/extensions/mysql/store/JdbcStoreConstraintTest.java new file mode 100644 index 0000000000..04623d0bff --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-mysql/src/test/java/io/agentscope/extensions/mysql/store/JdbcStoreConstraintTest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.mysql.store; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.sqlite.SQLiteDataSource; +import org.sqlite.SQLiteErrorCode; +import org.sqlite.SQLiteException; + +class JdbcStoreConstraintTest { + @TempDir Path temp; + private final List ns = List.of("test"); + + @Test + void duplicatePrimaryKeyReturnsFalseAndPreservesExistingItem() throws Exception { + JdbcStore store = open(""); + assertTrue(store.supportsAtomicSessionStorage()); + assertTrue(store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + StoreItem before = store.get(ns, "key"); + assertFalse(store.putIfVersion(ns, "key", Map.of("value", "replacement"), 0)); + assertEquals(before, store.get(ns, "key")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "CHECK (value_json NOT LIKE '%rejected%')", + "FOREIGN KEY (item_key) REFERENCES allowed_keys(item_key)", + "required_value INTEGER NOT NULL" + }) + void otherConstraintsPropagateWithoutCreatingAnItem(String constraint) throws Exception { + JdbcStore store = open(constraint); + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> store.putIfVersion(ns, "key", Map.of("value", "rejected"), 0)); + assertInstanceOf(SQLException.class, failure.getCause()); + assertNull(store.get(ns, "key")); + } + + @Test + void failedUpdatePreservesValueAndVersion() throws Exception { + JdbcStore store = open("CHECK (value_json NOT LIKE '%rejected%')"); + assertTrue(store.putIfVersion(ns, "key", Map.of("value", "original"), 0)); + StoreItem before = store.get(ns, "key"); + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + store.putIfVersion( + ns, "key", Map.of("value", "rejected"), before.version())); + assertInstanceOf(SQLException.class, failure.getCause()); + assertEquals(before, store.get(ns, "key")); + } + + @ParameterizedTest + @MethodSource("sqlErrors") + void onlySpecificDuplicateSignalsReturnFalse(SQLException cause, boolean duplicate) + throws Exception { + DataSource ds = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(ds.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenThrow(cause); + JdbcStore store = JdbcStore.builder(ds).dialect(new H2JdbcStoreDialect()).build(); + if (duplicate) { + assertFalse(store.putIfVersion(ns, "key", Map.of(), 0)); + } else { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> store.putIfVersion(ns, "key", Map.of(), 0)); + assertSame(cause, failure.getCause()); + } + } + + private static Stream sqlErrors() { + return Stream.of( + Arguments.of(new SQLException("duplicate", "23505"), true), + Arguments.of(new SQLException("MySQL duplicate", "23000", 1062), true), + Arguments.of(new SQLException("MySQL duplicate", "23000", 1022), true), + Arguments.of( + new SQLIntegrityConstraintViolationException("foreign key", "23000", 1452), + false), + Arguments.of( + new SQLIntegrityConstraintViolationException("not null", "23000", 1048), + false), + Arguments.of( + new SQLIntegrityConstraintViolationException("check", "23513", 23513), + false), + Arguments.of(new SQLException("generic constraint", "23000"), false), + Arguments.of(new SQLIntegrityConstraintViolationException("unknown"), false), + Arguments.of(new SQLException("connection", "08006"), false), + Arguments.of( + new SQLiteException( + "duplicate", SQLiteErrorCode.SQLITE_CONSTRAINT_PRIMARYKEY), + true), + Arguments.of( + new SQLiteException("duplicate", SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE), + true), + Arguments.of( + new SQLiteException("unknown", SQLiteErrorCode.SQLITE_CONSTRAINT), false)); + } + + private JdbcStore open(String extra) throws Exception { + SQLiteDataSource ds = new SQLiteDataSource(); + ds.setUrl("jdbc:sqlite:" + temp.resolve("store.db")); + ds.setEnforceForeignKeys(true); + try (Connection connection = ds.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE allowed_keys (item_key TEXT PRIMARY KEY)"); + statement.execute( + "CREATE TABLE agentscope_store (namespace_path TEXT NOT NULL," + + " item_key TEXT NOT NULL, value_json TEXT NOT NULL," + + " version BIGINT NOT NULL, updated_at BIGINT NOT NULL," + + (extra.isEmpty() ? "" : extra + ",") + + " PRIMARY KEY (namespace_path, item_key))"); + } + return JdbcStore.builder(ds).dialect(new SqliteJdbcStoreDialect()).build(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-postgresql/src/main/java/io/agentscope/extensions/postgresql/store/PostgresBaseStore.java b/agentscope-extensions/agentscope-extensions-postgresql/src/main/java/io/agentscope/extensions/postgresql/store/PostgresBaseStore.java index 14b0c6c925..d6cf70da02 100644 --- a/agentscope-extensions/agentscope-extensions-postgresql/src/main/java/io/agentscope/extensions/postgresql/store/PostgresBaseStore.java +++ b/agentscope-extensions/agentscope-extensions-postgresql/src/main/java/io/agentscope/extensions/postgresql/store/PostgresBaseStore.java @@ -24,7 +24,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; @@ -375,11 +374,7 @@ private static void validateKey(String key) { } private static boolean isDuplicateKey(SQLException e) { - if (e instanceof SQLIntegrityConstraintViolationException) { - return true; - } - String state = e.getSQLState(); - return state != null && state.startsWith("23"); + return "23505".equals(e.getSQLState()); } /** Builder for {@link PostgresBaseStore}. */ diff --git a/agentscope-extensions/agentscope-extensions-postgresql/src/test/java/io/agentscope/extensions/postgresql/store/PostgresBaseStoreTest.java b/agentscope-extensions/agentscope-extensions-postgresql/src/test/java/io/agentscope/extensions/postgresql/store/PostgresBaseStoreTest.java index be461b9cd8..fb89279f05 100644 --- a/agentscope-extensions/agentscope-extensions-postgresql/src/test/java/io/agentscope/extensions/postgresql/store/PostgresBaseStoreTest.java +++ b/agentscope-extensions/agentscope-extensions-postgresql/src/test/java/io/agentscope/extensions/postgresql/store/PostgresBaseStoreTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; @@ -45,6 +46,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -260,12 +263,30 @@ void putIfVersionInsertSucceeds() throws SQLException { @Test void putIfVersionInsertDuplicateReturnsFalse() throws SQLException { when(preparedStatement.executeUpdate()) - .thenThrow(new SQLIntegrityConstraintViolationException("dup")); + .thenThrow(new SQLIntegrityConstraintViolationException("duplicate key", "23505")); PostgresBaseStore store = PostgresBaseStore.builder(dataSource).build(); assertFalse(store.putIfVersion(List.of("a"), "key", Map.of(), 0L)); } + @ParameterizedTest + @ValueSource(strings = {"23502", "23503", "23514", "23000", ""}) + void putIfVersionPreservesNonDuplicateConstraintCauses(String state) throws SQLException { + SQLException cause = new SQLIntegrityConstraintViolationException("constraint", state); + when(preparedStatement.executeUpdate()).thenThrow(cause); + PostgresBaseStore store = PostgresBaseStore.builder(dataSource).build(); + + for (long version : new long[] {0, 3}) { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + store.putIfVersion( + List.of("a"), "key", Map.of("k", "v"), version)); + assertSame(cause, failure.getCause()); + } + } + @Test void putIfVersionInsertDuplicateBySqlStateReturnsFalse() throws SQLException { when(preparedStatement.executeUpdate()).thenThrow(new SQLException("dup", "23505")); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java index 5bc719f171..980e9804e5 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java @@ -25,7 +25,17 @@ * and identified by a key within each namespace. */ public interface BaseStore { - /** Explicit opt-in: versioned reads and durable cross-writer compare-and-set are supported. */ + /** + * Declares support for versioned reads and durable, atomic compare-and-set across all writers + * sharing the backend. + * + *

Stores returning {@code true} must return positive item versions. {@link #putIfVersion} + * creates only an absent item at expected version zero, or replaces only the observed positive + * version. Successful writes advance the version; a failed comparison writes nothing. Storage + * failures must propagate instead of being reported as version mismatches. + * + * @return whether the store provides this capability; false by default for legacy providers + */ default boolean supportsAtomicSessionStorage() { return false; } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java index 74681c770d..9cd407603b 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/InMemoryStore.java @@ -31,7 +31,7 @@ *

Every successful {@link #put} and {@link #putIfVersion} increments the item's * {@link StoreItem#version()} counter, enabling optimistic concurrency control. */ -public class InMemoryStore implements VersionedBaseStore { +public class InMemoryStore implements BaseStore { private final ConcurrentMap store = new ConcurrentHashMap<>(); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java deleted file mode 100644 index 7a1e53428b..0000000000 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/VersionedBaseStore.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.harness.agent.filesystem.remote.store; - -import java.util.List; -import java.util.Map; - -/** - * Optional capability for stores providing atomic item creation and versioned replacement. - * - *

All clients sharing a backend must observe the same positive item versions. Expected version - * zero creates only an absent item; a positive version replaces only that exact version. A failed - * comparison writes nothing. Successful writes advance the version. Storage failures propagate; - * implementations must not emulate CAS with an unprotected read followed by an unconditional put. - * This capability does not provide transactions across items or conditional deletion. - */ -public interface VersionedBaseStore extends BaseStore { - - /** - * Atomically creates or replaces one item according to the version contract of this capability. - * - * @param namespace hierarchical namespace path - * @param key item key - * @param value replacement value - * @param expectedVersion zero for creation, otherwise the observed positive version - * @return true on commit, false on version mismatch without writing - */ - @Override - boolean putIfVersion( - List namespace, String key, Map value, long expectedVersion); -} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java index 7d00bc78e5..a849883d45 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java @@ -18,7 +18,6 @@ import io.agentscope.core.state.AgentStateStore; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; -import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -572,9 +571,9 @@ public Mono completeTeam(String namespace, String teamName) { && item.value() != null && item.value().containsKey("sessionMembership"); if (sessionMembershipEnabled || adopted) { - if (!(store instanceof VersionedBaseStore)) { + if (!store.supportsAtomicSessionStorage()) { throw new IllegalStateException( - "Adopted Teams require VersionedBaseStore"); + "Adopted Teams require atomic versioned storage"); } // A stale unconditional fallback could erase concurrent adoption. for (int attempt = 0; attempt < 10; attempt++) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index bf209277e8..5e105d0f65 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -21,7 +21,6 @@ import io.agentscope.core.state.AgentStateStore; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; -import io.agentscope.harness.agent.filesystem.remote.store.VersionedBaseStore; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -68,16 +67,17 @@ public final class TeamSessionMembership { new ObjectMapper() .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS); - private final VersionedBaseStore store; + private final BaseStore store; private final String domain; private final Map stateStores; TeamSessionMembership( BaseStore store, String domain, Map stateStores) { - if (!(store instanceof VersionedBaseStore versioned)) { - throw new IllegalArgumentException("Session membership requires VersionedBaseStore"); + if (!store.supportsAtomicSessionStorage()) { + throw new IllegalArgumentException( + "Session membership requires atomic versioned storage"); } - this.store = versioned; + this.store = store; this.domain = text(domain, "relationship domain"); this.stateStores = Map.copyOf(stateStores); this.stateStores.keySet().forEach(id -> text(id, "state store domain")); @@ -644,8 +644,8 @@ static List projectMembers(BaseStore store, String namespace, St if (marker == null) { return null; } - if (!(store instanceof VersionedBaseStore)) { - throw new IllegalStateException("Adopted Teams require VersionedBaseStore"); + if (!store.supportsAtomicSessionStorage()) { + throw new IllegalStateException("Adopted Teams require atomic versioned storage"); } TeamAddress address = new TeamAddress(namespace, teamName); OwnerState state = readOwner(store, marker.domain(), marker.owner()).state(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index c5b60daa12..4c09d16eaa 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -15,6 +15,7 @@ */ package io.agentscope.harness.agent.team; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -32,6 +33,7 @@ import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.session.StoreSessionStorage; import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; import io.agentscope.harness.agent.team.TeamSessionMembership.Membership; import io.agentscope.harness.agent.team.TeamSessionMembership.Role; @@ -767,6 +769,71 @@ public StoreItem get(List ns, String key) { assertEquals("one", legacy("lead").sessionId()); } + @Test + void declaredBaseStoreCapabilitySupportsMembershipAndNativeSessionStorage() { + BaseStore capable = + new DelegatingStore(store) { + @Override + public boolean supportsAtomicSessionStorage() { + return true; + } + }; + client = new LocalTeamClient(capable); + service = client.sessionMembership("app", Map.of("state", states)); + MemberSession lead = member(states, "alice", "lead", "leader", "leader-agent"); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + Membership binding = service.bindMember("alice", a, worker).block(); + + LocalTeamClient reopened = new LocalTeamClient(capable); + TeamSessionMembership other = reopened.sessionMembership("app", Map.of("state", states)); + assertEquals(binding, other.findMembership(worker.session()).block()); + assertEquals("worker", legacy("worker").sessionId()); + // A reader that has not explicitly enabled membership must use the same capability. + new LocalTeamClient(capable).completeTeam(a.namespace(), a.teamName()).block(); + assertEquals("Completed", service.getTeam("alice", a).block().info().phase()); + assertTrue(other.unbindMember("alice", a, worker.session(), binding.token()).block()); + assertEquals("", legacy("worker").sessionId()); + assertTrue(states.exists("alice", "worker")); + + StoreSessionStorage nativeStorage = new StoreSessionStorage(capable, List.of("native")); + byte[] original = {1, 2, 3}; + assertTrue(nativeStorage.compareAndSet("journal", 0, original)); + var stored = nativeStorage.read("journal"); + assertTrue(stored.version() > 0); + assertFalse(nativeStorage.compareAndSet("journal", 0, new byte[] {4})); + assertArrayEquals(original, nativeStorage.read("journal").bytes()); + assertTrue(nativeStorage.compareAndSet("journal", stored.version(), new byte[] {4})); + } + + @Test + void casWithoutCapabilityRemainsLegacyOnlyAndCannotReadAdoptedMembership() { + BaseStore undeclared = new DelegatingStore(store); + LocalTeamClient legacyClient = new LocalTeamClient(undeclared); + assertFalse(undeclared.supportsAtomicSessionStorage()); + assertThrows( + IllegalArgumentException.class, + () -> legacyClient.sessionMembership("app", Map.of("state", states))); + assertThrows( + UnsupportedOperationException.class, + () -> new StoreSessionStorage(undeclared, List.of("native"))); + legacyClient.completeTeam(b.namespace(), b.teamName()).block(); + assertEquals( + "Completed", store.get(List.of("teams", "ns-b", "b"), "meta").value().get("phase")); + assertEquals(2, legacyClient.listMembers(a.namespace(), a.teamName()).block().size()); + + MemberSession lead = member(states, "alice", "lead", "leader", "leader-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + StoreItem before = store.get(List.of("teams", "ns-a", "a"), "meta"); + assertThrows( + IllegalStateException.class, + () -> legacyClient.listMembers(a.namespace(), a.teamName()).block()); + assertThrows( + IllegalStateException.class, + () -> legacyClient.completeTeam(a.namespace(), a.teamName()).block()); + assertEquals(before, store.get(List.of("teams", "ns-a", "a"), "meta")); + } + @Test void unenabledLegacyProviderAndRecordsKeepTheirContracts() throws Exception { BaseStore legacy = @@ -1186,6 +1253,40 @@ private static Map strip(Map value) { return out; } + private static class DelegatingStore implements BaseStore { + private final BaseStore delegate; + + DelegatingStore(BaseStore delegate) { + this.delegate = delegate; + } + + @Override + public StoreItem get(List ns, String key) { + return delegate.get(ns, key); + } + + @Override + public void put(List ns, String key, Map value) { + delegate.put(ns, key, value); + } + + @Override + public List search(List ns, int limit, int offset) { + return delegate.search(ns, limit, offset); + } + + @Override + public void delete(List ns, String key) { + delegate.delete(ns, key); + } + + @Override + public boolean putIfVersion( + List ns, String key, Map value, long expectedVersion) { + return delegate.putIfVersion(ns, key, value, expectedVersion); + } + } + private static class CountingStore extends InMemoryStore { final AtomicInteger ownerReads = new AtomicInteger(); final AtomicReference beforeOwnerCas = new AtomicReference<>(); diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index 71f4df5f0f..aa0074f1c7 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -46,7 +46,7 @@ Configure capabilities and use session operations as your application needs them ## Persistent Team membership for existing Sessions -`LocalTeamClient.sessionMembership(...)` explicitly enables adoption, binding, queries and safe removal for existing Leader and manually supplied **BYO** member Sessions. A definition's different Sessions may join different Teams; one exact Session may belong to only one Team in the configured relationship domain, including across Team namespaces. This programming API requires a `VersionedBaseStore`; the existing `InMemoryStore` and JDBC `JdbcStore` implement that capability. Other `BaseStore` and `TeamClient` implementations continue to work through their existing APIs. +`LocalTeamClient.sessionMembership(...)` explicitly enables adoption, binding, queries and safe removal for existing Leader and manually supplied **BYO** member Sessions. A definition's different Sessions may join different Teams; one exact Session may belong to only one Team in the configured relationship domain, including across Team namespaces. This programming API uses the existing `BaseStore` capability: `supportsAtomicSessionStorage()` must return `true`, with positive item versions and atomic `putIfVersion` across writers sharing the backend. A failed comparison writes nothing; storage errors must propagate. The existing `InMemoryStore` and JDBC `JdbcStore` declare this capability. Custom providers use the same contract; its default remains `false`. Other `BaseStore` and `TeamClient` implementations continue to work through their existing APIs. Create the Team through the existing `createTeam`, then adopt it with an existing persisted Leader Session. Bind only members already declared in that Team's directory. The example uses `.block()` at an application entry point; compose the returned `Mono` operations instead on reactive request paths. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index 3be36387c5..c25e468515 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -46,7 +46,7 @@ en_link: /v2/en/docs/harness/architecture ## 既有 Session 的持久 Team 归属 -`LocalTeamClient.sessionMembership(...)` 显式启用既有 Leader 和手工提供的 **BYO** 成员 Session 的接入、登记、查询与安全解除。同一定义的不同 Session 可以加入不同 Team;同一完整身份的 Session 在配置的关系域中只能属于一个 Team,包括跨 Team namespace 的情况。这是编程 API,需要 `VersionedBaseStore`;已有 `InMemoryStore` 和 JDBC `JdbcStore` 实现了该能力。其他 `BaseStore` 和 `TeamClient` 仍可通过原有 API 使用。 +`LocalTeamClient.sessionMembership(...)` 显式启用既有 Leader 和手工提供的 **BYO** 成员 Session 的接入、登记、查询与安全解除。同一定义的不同 Session 可以加入不同 Team;同一完整身份的 Session 在配置的关系域中只能属于一个 Team,包括跨 Team namespace 的情况。这是编程 API,复用已有 `BaseStore` 能力:`supportsAtomicSessionStorage()` 必须返回 `true`,提供正数记录版本,以及共享后端所有写入者之间原子的 `putIfVersion`。比较失败不得写入,真实存储错误必须传播。已有 `InMemoryStore` 和 JDBC `JdbcStore` 已声明该能力;自定义 provider 使用相同合同,默认仍为 `false`。其他 `BaseStore` 和 `TeamClient` 仍可通过原有 API 使用。 先通过已有 `createTeam` 建立 Team,再用已持久化的 Leader Session 显式接入。只能绑定已在该 Team 目录中声明的成员。下面在应用入口使用 `.block()`;响应式请求路径应组合返回的 `Mono`。 From 899482ad4ab5a389a962052e1211b2c98d100876 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Wed, 21 Oct 2026 00:50:22 +0800 Subject: [PATCH 11/13] docs(store): preserve existing comment wording --- .../jdbc/skill/JdbcAgentSkillRepository.java | 7 +++++-- .../extensions/mysql/store/JdbcStore.java | 13 ++++++++++++- .../agent/filesystem/remote/store/BaseStore.java | 5 +---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java index 3a871db883..61e517e3f4 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/main/java/io/agentscope/extensions/jdbc/skill/JdbcAgentSkillRepository.java @@ -989,10 +989,13 @@ private static void bindParams(PreparedStatement ps, List params) throws } /** - * Uses the shared JDBC duplicate classifier; other constraints remain storage failures. + * Whether the exception reports a duplicate-key / unique-constraint violation — the + * authoritative conflict signal for the {@code force=false} insert. Only positive + * duplicate signals match, so any other constraint failure surfaces as the driver's own + * {@link SQLException} instead of the destructive "use force=true" advice. * * @param e the exception thrown by the skill insert - * @return true only for a positively identified duplicate-key conflict + * @return true when the failure is a duplicate-key conflict rather than any other error */ static boolean isUniqueViolation(SQLException e) { return JdbcConstraintErrors.isDuplicateKey(e); diff --git a/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java b/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java index 31fe145849..a4c1ea09ce 100644 --- a/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java +++ b/agentscope-extensions/agentscope-extensions-mysql/src/main/java/io/agentscope/extensions/mysql/store/JdbcStore.java @@ -323,7 +323,18 @@ private static void validateKey(String key) { } } - /** Recognizes duplicate keys for the supported JDBC dialects, not other constraint failures. */ + /** + * Returns {@code true} if the given exception represents a primary-key / unique-constraint + * violation. Different JDBC drivers signal this differently: + * + *
    + *
  • Postgres / H2 use SQLSTATE {@code 23505}; MySQL / MariaDB use SQLSTATE {@code 23000} + * with error code {@code 1062} or {@code 1022}. + *
  • SQLite throws a plain {@code SQLiteException} with {@code sqlState=null} and + * {@code errorCode=19} ({@code SQLITE_CONSTRAINT}); we also key on the driver class name + * and require an extended {@code PRIMARYKEY} or {@code UNIQUE} result code. + *
+ */ private static boolean isDuplicateKey(SQLException e) { if ("23505".equals(e.getSQLState())) { return true; diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java index 980e9804e5..69ed8a691c 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/remote/store/BaseStore.java @@ -26,15 +26,12 @@ */ public interface BaseStore { /** - * Declares support for versioned reads and durable, atomic compare-and-set across all writers - * sharing the backend. + * Explicit opt-in: versioned reads and durable cross-writer compare-and-set are supported. * *

Stores returning {@code true} must return positive item versions. {@link #putIfVersion} * creates only an absent item at expected version zero, or replaces only the observed positive * version. Successful writes advance the version; a failed comparison writes nothing. Storage * failures must propagate instead of being reported as version mismatches. - * - * @return whether the store provides this capability; false by default for legacy providers */ default boolean supportsAtomicSessionStorage() { return false; From 76b1d904fc5843e1a86d152e8a3aa7366fc59701 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Sat, 24 Oct 2026 14:50:38 +0800 Subject: [PATCH 12/13] feat(harness): support native sessions in team membership --- .../teams/NativeTeamMembershipExample.java | 195 +++++ .../store/TeamSessionMembershipH2Test.java | 259 +++++- .../harness/agent/team/LocalTeamClient.java | 24 + .../agent/team/TeamSessionMembership.java | 120 ++- .../team/NativeTeamSessionMembershipTest.java | 767 ++++++++++++++++++ docs/v2/en/docs/harness/architecture.md | 41 +- docs/v2/zh/docs/harness/architecture.md | 41 +- 7 files changed, 1400 insertions(+), 47 deletions(-) create mode 100644 agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java new file mode 100644 index 0000000000..243d9a9bcf --- /dev/null +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java @@ -0,0 +1,195 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.examples.documentation2.harness.teams; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.session.SessionLogStore; +import io.agentscope.core.session.SessionProjection; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; +import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; +import io.agentscope.harness.agent.session.WorkspaceSessionLogStore; +import io.agentscope.harness.agent.team.LocalTeamClient; +import io.agentscope.harness.agent.team.TeamCreateSpec; +import io.agentscope.harness.agent.team.TeamMemberSpec; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import reactor.core.publisher.Flux; + +/** Registers existing native Sessions through the programming API, without provider credentials. */ +public final class NativeTeamMembershipExample { + private static final Duration WAIT = Duration.ofSeconds(20); + private static final String LOG_DOMAIN = "example-workspace-logs"; + private static final String RELATIONSHIP_DOMAIN = "example-relations"; + + private NativeTeamMembershipExample() {} + + /** Runs with an optional workspace directory. Only the Session logs survive process exit. */ + public static void main(String[] args) throws Exception { + Path workspace = + args.length == 0 + ? Files.createTempDirectory("native-team-membership-") + : Path.of(args[0]); + var logs = new WorkspaceSessionLogStore(new LocalFilesystem(workspace)); + var leaderContext = context(); + var workerContext = context(); + MemberSession leader; + MemberSession worker; + try (var leaderAgent = agent(workspace, logs, "coordinator-runtime"); + var workerAgent = agent(workspace, logs, "research-runtime")) { + leaderAgent.call("Initialize the coordinator conversation.", leaderContext).block(WAIT); + workerAgent.call("Initialize the researcher conversation.", workerContext).block(WAIT); + leader = + new MemberSession( + "lead", + "demo", + "coordinator-definition", + Role.LEADER, + SessionKey.nativeSession( + LOG_DOMAIN, leaderAgent.sessionKey(leaderContext))); + worker = + new MemberSession( + "researcher", + "demo", + "research-definition", + Role.WORKER, + SessionKey.nativeSession( + LOG_DOMAIN, workerAgent.sessionKey(workerContext))); + } + + var teamStore = new InMemoryStore(); + var client = new LocalTeamClient(teamStore); + var address = new TeamAddress("example-app", "research"); + client.createTeam( + new TeamCreateSpec( + address.teamName(), + address.namespace(), + "Compare evidence", + "coordinator-definition", + "", + List.of( + new TeamMemberSpec( + "researcher", "research-definition", "", "byo")))) + .block(WAIT); + var membership = + client.sessionMembership(RELATIONSHIP_DOMAIN, Map.of(), Map.of(LOG_DOMAIN, logs)); + var team = + Objects.requireNonNull( + membership + .adoptTeam("demo", address, "Research", List.of(leader)) + .block(WAIT)); + var binding = + Objects.requireNonNull(membership.bindMember("demo", address, worker).block(WAIT)); + + var reopenedLogs = new WorkspaceSessionLogStore(new LocalFilesystem(workspace)); + var reopened = + new LocalTeamClient(teamStore) + .sessionMembership( + RELATIONSHIP_DOMAIN, Map.of(), Map.of(LOG_DOMAIN, reopenedLogs)); + var current = reopened.findMembership(worker.session()).block(WAIT); + var reopenedTeam = Objects.requireNonNull(reopened.getTeam("demo", address).block(WAIT)); + var members = Objects.requireNonNull(reopened.listMemberships("demo", address).block(WAIT)); + if (!binding.equals(current) + || !team.teamId().equals(reopenedTeam.teamId()) + || members.size() != 2) { + throw new IllegalStateException("Reopening changed the Team associations"); + } + + boolean removed = + Boolean.TRUE.equals( + reopened.unbindMember("demo", address, worker.session(), binding.token()) + .block(WAIT)); + if (!removed || reopened.findMembership(worker.session()).block(WAIT) != null) { + throw new IllegalStateException("The expected binding was not removed"); + } + var nativeKey = + new io.agentscope.core.session.SessionKey( + worker.session().owner(), + worker.session().agentId(), + worker.session().sessionId()); + if (SessionProjection.read(reopenedLogs.open(nativeKey, workerContext)).stateJson() + == null) { + throw new IllegalStateException("Removing membership deleted the native Session"); + } + System.out.println("Team: " + team.teamId()); + System.out.println("Registered two existing Sessions and recovered both associations."); + System.out.println( + "Removed the researcher association; its committed Session log remains."); + System.out.println("Native log workspace: " + workspace.toAbsolutePath()); + System.out.println( + "Team records use InMemoryStore in this example. Use a durable JdbcStore to retain" + + " associations across process restarts."); + } + + private static RuntimeContext context() { + return RuntimeContext.builder() + .userId("demo") + .sessionId(UUID.randomUUID().toString()) + .build(); + } + + private static HarnessAgent agent(Path workspace, SessionLogStore logs, String agentId) { + return HarnessAgent.builder() + .agentId(agentId) + .workspace(workspace) + .model(new InitializationModel()) + .sessionLogStore(logs) + .disableShellTool() + .disableFilesystemTools() + .disableWebTools() + .disableSubagents() + .disableDynamicSubagents() + .disableDynamicSkills() + .disableDefaultWorkspaceSkills() + .disableMemoryTools() + .disableMemoryHooks() + .disableToolsConfig() + .disableWorkspaceContext() + .disableAtPathExpansion() + .build(); + } + + private static final class InitializationModel implements Model { + @Override + public String getModelName() { + return "scripted-session-initialization"; + } + + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.just( + ChatResponse.builder() + .content(List.of(TextBlock.builder().text("Session ready.").build())) + .build()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java index 96b319d19f..0c25c1ce7e 100644 --- a/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java +++ b/agentscope-extensions/agentscope-extensions-jdbc/src/test/java/io/agentscope/extensions/jdbc/store/TeamSessionMembershipH2Test.java @@ -19,17 +19,30 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.session.SessionLogStore; +import io.agentscope.core.session.SessionProjection; import io.agentscope.core.state.State; import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect; import io.agentscope.extensions.jdbc.state.JdbcAgentStateStore; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; +import io.agentscope.harness.agent.session.WorkspaceSessionLogStore; import io.agentscope.harness.agent.team.LocalTeamClient; import io.agentscope.harness.agent.team.TeamConflictException; import io.agentscope.harness.agent.team.TeamCreateSpec; @@ -45,6 +58,7 @@ import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; @@ -58,6 +72,8 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Flux; class TeamSessionMembershipH2Test { @TempDir Path temp; @@ -401,39 +417,73 @@ void fileDatabaseReopenPreservesMembershipTokensMetadataAndExistingSessionState( shutdown(finalRead.ds()); } - @Test - void independentJdbcStoresAtomicallyCompeteAndIdenticalRequestsRetainOneToken() - throws Exception { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void independentJdbcStoresAtomicallyCompeteAndIdenticalRequestsRetainOneToken( + boolean nativeSessions) throws Exception { Fixture first = open(), second = open(); + var firstLogs = + new WorkspaceSessionLogStore(new LocalFilesystem(temp.resolve("native-workspace"))); + var secondLogs = + new WorkspaceSessionLogStore(new LocalFilesystem(temp.resolve("native-workspace"))); + Map firstDomains = + nativeSessions ? Map.of("native-logs", firstLogs) : Map.of(); + Map secondDomains = + nativeSessions ? Map.of("native-logs", secondLogs) : Map.of(); + var firstMembership = + first.client() + .sessionMembership("app", Map.of("sessions", first.states()), firstDomains); + var secondMembership = + second.client() + .sessionMembership( + "app", Map.of("sessions", second.states()), secondDomains); create(first.client(), a); create(first.client(), b); - first.membership() + firstMembership .adoptTeam( "alice", a, "same display", - List.of(member(first, "lead", "one", "leader-agent"))) + List.of( + competitionMember( + first, + "lead", + "one", + "leader-agent", + nativeSessions ? firstLogs : null))) .block(); - first.membership() + firstMembership .adoptTeam( "alice", b, "same display", - List.of(member(first, "lead", "two", "leader-agent"))) + List.of( + competitionMember( + first, + "lead", + "two", + "leader-agent", + nativeSessions ? firstLogs : null))) .block(); - MemberSession worker = member(first, "worker", "worker", "worker-agent"); + MemberSession worker = + competitionMember( + first, + "worker", + "worker", + "worker-agent", + nativeSessions ? firstLogs : null); CyclicBarrier barrier = new CyclicBarrier(2); AtomicInteger writes = new AtomicInteger(); - TeamSessionMembership left = racing(first, barrier, writes); - TeamSessionMembership right = racing(second, barrier, writes); + TeamSessionMembership left = racing(first, barrier, writes, firstDomains); + TeamSessionMembership right = racing(second, barrier, writes, secondDomains); CompletableFuture x = attempt(() -> left.bindMember("alice", a, worker).block()); CompletableFuture y = attempt(() -> right.bindMember("alice", b, worker).block()); List outcomes = List.of(x.get(10, TimeUnit.SECONDS), y.get(10, TimeUnit.SECONDS)); assertEquals(1, outcomes.stream().filter(Membership.class::isInstance).count()); assertEquals(1, outcomes.stream().filter(TeamConflictException.class::isInstance).count()); - Membership winner = second.membership().findMembership(worker.session()).block(); - assertEquals(winner, first.membership().findMembership(worker.session()).block()); - first.membership() + Membership winner = secondMembership.findMembership(worker.session()).block(); + assertEquals(winner, firstMembership.findMembership(worker.session()).block()); + firstMembership .unbindMember("alice", winner.address(), worker.session(), winner.token()) .block(); writes.set(0); @@ -442,7 +492,7 @@ void independentJdbcStoresAtomicallyCompeteAndIdenticalRequestsRetainOneToken() Object lx = x.get(10, TimeUnit.SECONDS), ry = y.get(10, TimeUnit.SECONDS); assertTrue(lx instanceof Membership); assertEquals(lx, ry); - assertEquals(lx, second.membership().findMembership(worker.session()).block()); + assertEquals(lx, secondMembership.findMembership(worker.session()).block()); shutdown(first.ds()); } @@ -503,6 +553,167 @@ public boolean putIfVersion( shutdown(reopened.ds()); } + @Test + void nativeAssociationsSurviveDatabaseAndSessionLogReopenAndSafeRemoval() throws Exception { + Path nativeWorkspace = temp.resolve("native-workspace"); + var logs = new WorkspaceSessionLogStore(new LocalFilesystem(nativeWorkspace)); + var leaderKey = + SessionKey.nativeSession( + "native-logs", + new io.agentscope.core.session.SessionKey( + "alice", "runtime-leader", "leader-session")); + var workerKey = + SessionKey.nativeSession( + "native-logs", + new io.agentscope.core.session.SessionKey( + "alice", "runtime-worker", "worker-session")); + var leader = + new MemberSession( + "lead", "definition-owner", "leader-agent", Role.LEADER, leaderKey); + var worker = + new MemberSession( + "worker", "definition-owner", "worker-agent", Role.WORKER, workerKey); + persistNative(leaderKey, logs); + persistNative(workerKey, logs); + var nativeWorkerKey = + new io.agentscope.core.session.SessionKey( + "alice", "runtime-worker", "worker-session"); + var workerContext = + RuntimeContext.builder().userId("alice").sessionId("worker-session").build(); + long committedSeq = logs.open(nativeWorkerKey, workerContext).head().seq(); + Fixture initial = open(); + try { + create(initial.client(), a); + var membership = + initial.client() + .sessionMembership( + "app", + Map.of("sessions", initial.states()), + Map.of("native-logs", logs)); + var team = membership.adoptTeam("alice", a, "Native display", List.of(leader)).block(); + Membership binding = membership.bindMember("alice", a, worker).block(); + assertFalse(initial.states().exists("alice", "leader-session")); + assertFalse(initial.states().exists("alice", "worker-session")); + shutdown(initial.ds()); + + Fixture reopened = open(); + var reopenedLogs = new WorkspaceSessionLogStore(new LocalFilesystem(nativeWorkspace)); + var reopenedMembership = + reopened.client() + .sessionMembership( + "app", + Map.of("sessions", reopened.states()), + Map.of("native-logs", reopenedLogs)); + assertEquals(binding, reopenedMembership.findMembership(workerKey).block()); + assertEquals(team.teamId(), reopenedMembership.getTeam("alice", a).block().teamId()); + assertEquals( + "Native display", reopenedMembership.getTeam("alice", a).block().displayName()); + assertEquals(2, reopenedMembership.listMemberships("alice", a).block().size()); + assertEquals( + "worker-session", + reopened.client().listMembers("ns-a", "a").block().stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + assertTrue( + reopenedMembership + .unbindMember("alice", a, workerKey, binding.token()) + .block()); + var rebound = reopenedMembership.bindMember("alice", a, worker).block(); + assertNotEquals(binding.token(), rebound.token()); + assertThrows( + TeamConflictException.class, + () -> + reopenedMembership + .unbindMember("alice", a, workerKey, binding.token()) + .block()); + assertEquals(rebound, reopenedMembership.findMembership(workerKey).block()); + assertTrue( + reopenedMembership + .unbindMember("alice", a, workerKey, rebound.token()) + .block()); + shutdown(reopened.ds()); + + Fixture finalRead = open(); + var finalLogs = new WorkspaceSessionLogStore(new LocalFilesystem(nativeWorkspace)); + var finalMembership = + finalRead + .client() + .sessionMembership( + "app", + Map.of("sessions", finalRead.states()), + Map.of("native-logs", finalLogs)); + assertNull(finalMembership.findMembership(workerKey).block()); + assertEquals(1, finalMembership.listMemberships("alice", a).block().size()); + assertEquals( + "", + finalRead.client().listMembers("ns-a", "a").block().stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + assertEquals(committedSeq, finalLogs.open(nativeWorkerKey, workerContext).head().seq()); + assertNotNull( + SessionProjection.read(finalLogs.open(nativeWorkerKey, workerContext)) + .stateJson()); + assertFalse(finalRead.states().exists("alice", "worker-session")); + } finally { + shutdown(initial.ds()); + } + } + + private void persistNative(SessionKey key, SessionLogStore logs) { + Model model = + new Model() { + public String getModelName() { + return "native-jdbc-fixture"; + } + + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.just( + ChatResponse.builder() + .content( + List.of( + TextBlock.builder() + .text("initialized") + .build())) + .build()); + } + }; + try (var agent = + HarnessAgent.builder() + .agentId(key.agentId()) + .workspace(temp.resolve("native-workspace")) + .model(model) + .sessionLogStore(logs) + .disableShellTool() + .disableFilesystemTools() + .disableWebTools() + .disableSubagents() + .disableDynamicSubagents() + .disableDynamicSkills() + .disableDefaultWorkspaceSkills() + .disableMemoryTools() + .disableMemoryHooks() + .disableToolsConfig() + .disableWorkspaceContext() + .disableAtPathExpansion() + .build()) { + assertEquals( + "initialized", + agent.call( + "initialize", + RuntimeContext.builder() + .userId(key.owner()) + .sessionId(key.sessionId()) + .build()) + .block(Duration.ofSeconds(15)) + .getTextContent()); + } + } + private Fixture open() { JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:file:" + temp.resolve("teams").toAbsolutePath() + ";DB_CLOSE_DELAY=-1"); @@ -555,8 +766,24 @@ private static MemberSession member( new SessionKey("sessions", owner, sessionId)); } + private MemberSession competitionMember( + Fixture fixture, String name, String sessionId, String agentRef, SessionLogStore logs) { + if (logs == null) return member(fixture, name, sessionId, agentRef); + var key = new SessionKey("native-logs", "alice", sessionId, "runtime-" + agentRef); + persistNative(key, logs); + return new MemberSession( + name, + "definition-owner", + agentRef, + name.equals("lead") ? Role.LEADER : Role.WORKER, + key); + } + private static TeamSessionMembership racing( - Fixture fixture, CyclicBarrier barrier, AtomicInteger writes) { + Fixture fixture, + CyclicBarrier barrier, + AtomicInteger writes, + Map logs) { DelegatingStore controlled = new DelegatingStore(fixture.store()) { @Override @@ -574,7 +801,7 @@ public boolean putIfVersion( } }; return new LocalTeamClient(controlled) - .sessionMembership("app", Map.of("sessions", fixture.states())); + .sessionMembership("app", Map.of("sessions", fixture.states()), logs); } private static CompletableFuture attempt(Callable action) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java index a849883d45..db76e3cf19 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java @@ -15,6 +15,7 @@ */ package io.agentscope.harness.agent.team; +import io.agentscope.core.session.SessionLogStore; import io.agentscope.core.state.AgentStateStore; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; @@ -65,6 +66,29 @@ public TeamSessionMembership sessionMembership( return membership; } + /** + * Enables persistent associations for existing legacy and native Sessions. + * + *

Native domain IDs identify actual log backends and their partition routing. Log stores + * receive the member's user/session context; bind any additional routing attributes in a + * configured store adapter. Upgrade and drain older writers before storing native records. + * + * @param relationDomain shared relationship authority domain + * @param stateStores legacy state domains; existing mappings retain their meaning + * @param sessionLogs native log domains, disjoint from legacy domains + * @return adoption, binding, query and safe removal operations on this client's Team store + * @throws IllegalArgumentException if the store lacks atomic version support or domains overlap + */ + public TeamSessionMembership sessionMembership( + String relationDomain, + Map stateStores, + Map sessionLogs) { + TeamSessionMembership membership = + new TeamSessionMembership(store, relationDomain, stateStores, sessionLogs); + sessionMembershipEnabled = true; + return membership; + } + @Override public Mono> listTasks(String namespace, String teamName) { return Mono.fromCallable(() -> readAllTasks(namespace, teamName)) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java index 5e105d0f65..bf0dfd63ab 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/TeamSessionMembership.java @@ -15,9 +15,13 @@ */ package io.agentscope.harness.agent.team; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.session.SessionLogStore; +import io.agentscope.core.session.SessionProjection; import io.agentscope.core.state.AgentStateStore; import io.agentscope.harness.agent.filesystem.remote.store.BaseStore; import io.agentscope.harness.agent.filesystem.remote.store.StoreItem; @@ -70,9 +74,18 @@ public final class TeamSessionMembership { private final BaseStore store; private final String domain; private final Map stateStores; + private final Map sessionLogs; TeamSessionMembership( BaseStore store, String domain, Map stateStores) { + this(store, domain, stateStores, Map.of()); + } + + TeamSessionMembership( + BaseStore store, + String domain, + Map stateStores, + Map sessionLogs) { if (!store.supportsAtomicSessionStorage()) { throw new IllegalArgumentException( "Session membership requires atomic versioned storage"); @@ -81,6 +94,11 @@ public final class TeamSessionMembership { this.domain = text(domain, "relationship domain"); this.stateStores = Map.copyOf(stateStores); this.stateStores.keySet().forEach(id -> text(id, "state store domain")); + this.sessionLogs = Map.copyOf(sessionLogs); + this.sessionLogs.keySet().forEach(id -> text(id, "session log domain")); + if (this.sessionLogs.keySet().stream().anyMatch(this.stateStores::containsKey)) { + throw new IllegalArgumentException("Legacy and native stores need distinct domain IDs"); + } } /** Declared responsibility, independent of how a member's resources were obtained. */ @@ -109,17 +127,43 @@ public record TeamAddress(String namespace, String teamName) { } /** - * Exact Session slot in a configured state store domain. Null owner means anonymous. - * @param stateStoreDomain stable identifier for the actual state store partition + * Exact legacy state slot or native Session in a configured physical storage domain. + * Null owner means anonymous; empty owners and the reserved alias {@code __anon__} are rejected. + * A nonnull agentId selects native log identity, independently of the definition reference. + * @param stateStoreDomain stable identifier for the actual state or native log partition * @param owner nullable Session owner * @param sessionId Session identifier within that partition and owner + * @param agentId native running Agent ID; null selects the legacy state slot */ - public record SessionKey(String stateStoreDomain, String owner, String sessionId) { + public record SessionKey( + String stateStoreDomain, + String owner, + String sessionId, + @JsonInclude(JsonInclude.Include.NON_NULL) String agentId) { + /** Preserves the legacy state-store slot constructor and JSON shape. */ + public SessionKey(String stateStoreDomain, String owner, String sessionId) { + this(stateStoreDomain, owner, sessionId, null); + } + + /** + * Builds a complete native identity in the application's actual log-store domain. + * @param logDomain stable identifier for the actual log backend and partition routing + * @param key existing native Session identity; no owner normalization is performed + * @return identity for registration, lookup and token-checked removal + */ + public static SessionKey nativeSession( + String logDomain, io.agentscope.core.session.SessionKey key) { + return new SessionKey(logDomain, key.userId(), key.sessionId(), key.agentId()); + } + /** Validates the slot, rejecting reserved anonymous aliases. */ public SessionKey { text(stateStoreDomain, "state store domain"); validOwner(owner); text(sessionId, "session ID"); + if (agentId != null) { + text(agentId, "native agent ID"); + } } } @@ -253,7 +297,7 @@ public Mono adoptTeam( } Marker next = new Marker( - 1, + schema(initial), domain, owner, UUID.randomUUID().toString(), @@ -345,13 +389,13 @@ public Mono getTeam(String owner, TeamAddress address) { /** * Finds the current association of a precisely identified Session, including across namespaces. - * @param session Session identity in a configured state store domain + * @param session Session identity in a configured state or native log domain * @return empty if unbound; does not require the Session still to exist */ public Mono findMembership(SessionKey session) { return io( () -> { - requireStateStore(session); + requireDomain(session); return readOwner(session.owner()).state().memberships().stream() .filter(m -> m.member().session().equals(session)) .findFirst() @@ -499,18 +543,43 @@ private void validateMember(String owner, TeamAddress address, MemberSession mem || declared.isLead() != (member.role() == Role.LEADER)) { throw new TeamConflictException("Member definition or declared role does not match"); } - if (!requireStateStore(member.session()).exists(owner, member.session().sessionId())) { - throw new IllegalArgumentException( - "Session has no persisted state in the configured domain"); + requirePersistedSession(member.session()); + } + + private void requireDomain(SessionKey session) { + boolean known = + session.agentId() == null + ? stateStores.containsKey(session.stateStoreDomain()) + : sessionLogs.containsKey(session.stateStoreDomain()); + if (!known) { + throw new IllegalArgumentException("Unknown Session store domain or identity kind"); } } - private AgentStateStore requireStateStore(SessionKey session) { - AgentStateStore result = stateStores.get(session.stateStoreDomain()); - if (result == null) { - throw new IllegalArgumentException("Unknown state store domain"); + private void requirePersistedSession(SessionKey session) { + requireDomain(session); + if (session.agentId() == null) { + if (!stateStores + .get(session.stateStoreDomain()) + .exists(session.owner(), session.sessionId())) { + throw new IllegalArgumentException( + "Session has no persisted state in the configured domain"); + } + } else { + var key = + new io.agentscope.core.session.SessionKey( + session.owner(), session.agentId(), session.sessionId()); + var context = + RuntimeContext.builder() + .userId(session.owner()) + .sessionId(session.sessionId()) + .build(); + var log = sessionLogs.get(session.stateStoreDomain()).open(key, context); + if (SessionProjection.read(log).stateJson() == null) { + throw new IllegalArgumentException( + "Native Session has no committed baseline in the configured domain"); + } } - return result; } private StoreItem requireMeta(TeamAddress address) { @@ -554,7 +623,12 @@ private static OwnerItem readOwner(BaseStore store, String domain, String owner) return new OwnerItem(0, new OwnerState(1, Map.of(), List.of())); } OwnerState state = decodeOwner(item.value()); - if (state.schema() != 1) { + if (state.schema() != 1 && state.schema() != 2) { + throw corrupt(); + } + if (state.schema() == 1 + && state.memberships().stream() + .anyMatch(m -> m.member().session().agentId() != null)) { throw corrupt(); } Set sessions = new HashSet<>(); @@ -592,7 +666,12 @@ private boolean writeOwner( ownerKey(owner), encode( new StoredOwnerState( - 1, + Math.max( + current.state().schema(), + schema( + memberships.stream() + .map(Membership::member) + .toList())), receipts, memberships.stream().map(StoredMembership::from).toList())), current.version()); @@ -693,7 +772,7 @@ private static Marker marker(StoreItem meta) { return null; } Marker marker = decode(meta.value().get(MARKER), Marker.class); - if (marker.schema() != 1) { + if (marker.schema() != 1 && marker.schema() != 2) { throw corrupt(); } return marker; @@ -714,6 +793,9 @@ private record Marker( text(displayName, "display name"); text(registrationToken, "registration token"); initial = canonical(initial); + if (schema != TeamSessionMembership.schema(initial)) { + throw corrupt(); + } if (initial.stream().filter(m -> m.role() == Role.LEADER).count() != 1) { throw corrupt(); } @@ -818,6 +900,10 @@ private static List canonical(List initial) { .toList(); } + private static int schema(List members) { + return members.stream().anyMatch(m -> m.session().agentId() != null) ? 2 : 1; + } + private static List teamNs(TeamAddress address) { return List.of("teams", address.namespace(), address.teamName()); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java new file mode 100644 index 0000000000..582d419caf --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java @@ -0,0 +1,767 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.team; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.session.InMemorySessionLogStore; +import io.agentscope.core.session.SessionEvent; +import io.agentscope.core.session.SessionLog; +import io.agentscope.core.session.SessionLogStore; +import io.agentscope.core.session.SessionProjection; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.core.state.State; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.filesystem.BakedContextFilesystem; +import io.agentscope.harness.agent.filesystem.remote.RemoteFilesystem; +import io.agentscope.harness.agent.filesystem.remote.store.InMemoryStore; +import io.agentscope.harness.agent.session.WorkspaceSessionLogStore; +import io.agentscope.harness.agent.team.TeamSessionMembership.MemberSession; +import io.agentscope.harness.agent.team.TeamSessionMembership.Membership; +import io.agentscope.harness.agent.team.TeamSessionMembership.Role; +import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; +import io.agentscope.harness.agent.team.TeamSessionMembership.Status; +import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Flux; + +class NativeTeamSessionMembershipTest { + private static final Duration WAIT = Duration.ofSeconds(15); + private static final List RELATION_NS = + List.of("team-session-membership", "cmVsYXRpb25z"); + @TempDir Path workspace; + private final InMemorySessionLogStore logs = new InMemorySessionLogStore(); + private final TeamAddress a = new TeamAddress("ns", "a"); + private final TeamAddress b = new TeamAddress("other-ns", "b"); + + @Test + void existingNativeSessionsSupportAdoptionBindingQueriesAndTokenSafeRemoval() { + var store = new InMemoryStore(); + var client = new LocalTeamClient(store); + var service = client.sessionMembership("relations", Map.of(), Map.of("journal", logs)); + create(client, a); + MemberSession leader = + member("lead", "journal", "alice", "runtime-leader", "leader-session"); + MemberSession worker = + member("worker", "journal", "alice", "runtime-worker", "worker-session"); + persist(leader, logs); + persist(worker, logs); + long committedSeq = log(worker.session(), logs).head().seq(); + + var team = service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT); + assertEquals(Status.ACTIVE, team.status()); + Membership binding = service.bindMember("alice", a, worker).block(WAIT); + assertEquals(binding, service.findMembership(worker.session()).block(WAIT)); + assertEquals(2, service.getTeam("alice", a).block(WAIT).memberships().size()); + assertEquals(2, service.listMemberships("alice", a).block(WAIT).size()); + assertEquals( + "worker-session", + client.listMembers("ns", "a").block(WAIT).stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + assertEquals("worker-definition", binding.member().agentRef()); + assertEquals("runtime-worker", binding.member().session().agentId()); + + var reopened = + new LocalTeamClient(store) + .sessionMembership("relations", Map.of(), Map.of("journal", logs)); + assertEquals(binding, reopened.findMembership(worker.session()).block(WAIT)); + assertTrue( + reopened.unbindMember("alice", a, worker.session(), binding.token()).block(WAIT)); + assertFalse( + reopened.unbindMember("alice", a, worker.session(), binding.token()).block(WAIT)); + var rebound = reopened.bindMember("alice", a, worker).block(WAIT); + assertNotEquals(binding.token(), rebound.token()); + assertThrows( + TeamConflictException.class, + () -> + reopened.unbindMember("alice", a, worker.session(), binding.token()) + .block(WAIT)); + assertEquals(rebound, reopened.findMembership(worker.session()).block(WAIT)); + assertTrue( + reopened.unbindMember("alice", a, worker.session(), rebound.token()).block(WAIT)); + assertNull(reopened.findMembership(worker.session()).block(WAIT)); + assertEquals(committedSeq, log(worker.session(), logs).head().seq()); + assertNotNull(SessionProjection.read(log(worker.session(), logs)).stateJson()); + assertEquals( + "", + client.listMembers("ns", "a").block(WAIT).stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + assertEquals(team.teamId(), reopened.getTeam("alice", a).block(WAIT).teamId()); + } + + @Test + void fullNativeIdentityAndActualLogDomainsRemainIsolated() { + var otherLogs = new InMemorySessionLogStore(); + var client = new LocalTeamClient(new InMemoryStore()); + var service = + client.sessionMembership( + "relations", Map.of(), Map.of("journal", logs, "other-journal", otherLogs)); + List identities = + List.of( + member("lead", "journal", "alice", "agent", "session"), + member("lead", "journal", "bob", "agent", "session"), + member("lead", "journal", "alice", "other-agent", "session"), + member("lead", "journal", "alice", "agent", "other-session"), + member("lead", "journal", null, "agent", "session"), + member("lead", "other-journal", "alice", "agent", "session")); + Set teamIds = new HashSet<>(); + for (int i = 0; i < identities.size(); i++) { + MemberSession member = identities.get(i); + persist( + member, + member.session().stateStoreDomain().equals("journal") ? logs : otherLogs); + var address = new TeamAddress("ns", "identity-" + i); + create(client, address); + var team = + service.adoptTeam( + member.session().owner(), + address, + "Same display", + List.of(member)) + .block(WAIT); + assertTrue(teamIds.add(team.teamId())); + assertEquals(member, service.findMembership(member.session()).block(WAIT).member()); + } + var first = identities.get(0); + assertEquals( + first.session(), + SessionKey.nativeSession( + "journal", + new io.agentscope.core.session.SessionKey("alice", "agent", "session"))); + assertThrows( + TeamConflictException.class, + () -> + service.adoptTeam( + "alice", + new TeamAddress("ns", "identity-2"), + "Same display", + List.of(first)) + .block(WAIT)); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "__anon__", "alice\n"}) + void unsupportedNativeOwnersAreRejectedWithoutChangingLegacyValidation(String owner) { + assertThrows( + IllegalArgumentException.class, + () -> new SessionKey("journal", owner, "session", "agent")); + assertThrows( + IllegalArgumentException.class, + () -> + SessionKey.nativeSession( + "journal", + new io.agentscope.core.session.SessionKey( + owner, "agent", "session"))); + assertThrows( + IllegalArgumentException.class, () -> new SessionKey("legacy", owner, "session")); + } + + @Test + void nativeAndLegacyDomainMappingsCannotOverlapOrReinterpretSessionKeys() { + var states = new InMemoryAgentStateStore(); + var client = new LocalTeamClient(new InMemoryStore()); + assertThrows( + IllegalArgumentException.class, + () -> + client.sessionMembership( + "relations", Map.of("same", states), Map.of("same", logs))); + var service = + client.sessionMembership( + "relations", Map.of("legacy", states), Map.of("journal", logs)); + assertThrows( + IllegalArgumentException.class, + () -> + service.findMembership( + new SessionKey("legacy", "alice", "session", "agent")) + .block(WAIT)); + assertThrows( + IllegalArgumentException.class, + () -> + service.findMembership(new SessionKey("journal", "alice", "session")) + .block(WAIT)); + } + + @Test + void boundTenantRoutingUsesActualPartitionsOfTheSameLogBackend() { + var backend = new InMemoryStore(); + var filesystem = + new RemoteFilesystem( + backend, context -> List.of("tenant", (String) context.get("tenant"))); + var tenantA = + new WorkspaceSessionLogStore( + new BakedContextFilesystem( + filesystem, RuntimeContext.builder().put("tenant", "a").build())); + var tenantB = + new WorkspaceSessionLogStore( + new BakedContextFilesystem( + filesystem, RuntimeContext.builder().put("tenant", "b").build())); + var client = new LocalTeamClient(new InMemoryStore()); + var service = + client.sessionMembership( + "relations", Map.of(), Map.of("tenant-a", tenantA, "tenant-b", tenantB)); + create(client, a); + create(client, b); + var first = member("lead", "tenant-a", "alice", "agent", "session"); + var second = member("lead", "tenant-b", "alice", "agent", "session"); + persist(first, tenantA); + assertNotNull(SessionProjection.read(log(first.session(), tenantA)).stateJson()); + assertNull(SessionProjection.read(log(second.session(), tenantB)).stateJson()); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", b, "Tenant B", List.of(second)).block(WAIT)); + var firstTeam = service.adoptTeam("alice", a, "Tenant A", List.of(first)).block(WAIT); + persist(second, tenantB); + var secondTeam = service.adoptTeam("alice", b, "Tenant B", List.of(second)).block(WAIT); + assertNotEquals(firstTeam.teamId(), secondTeam.teamId()); + assertEquals( + firstTeam.teamId(), service.findMembership(first.session()).block(WAIT).teamId()); + assertEquals( + secondTeam.teamId(), service.findMembership(second.session()).block(WAIT).teamId()); + assertNotNull(SessionProjection.read(log(second.session(), tenantB)).stateJson()); + } + + @Test + void openingAnEmptyLogOrPersistingInAnotherDomainDoesNotEstablishSessionExistence() { + var store = new InMemoryStore(); + var client = new LocalTeamClient(store); + var emptyLogs = new InMemorySessionLogStore(); + var states = new InMemoryAgentStateStore(); + states.save("alice", "session", "example", new Saved("legacy-only")); + var service = + client.sessionMembership( + "relations", + Map.of("legacy", states), + Map.of("journal", logs, "empty-journal", emptyLogs)); + create(client, a); + var leader = member("lead", "journal", "alice", "agent", "session"); + assertEquals(0, log(leader.session(), logs).head().seq()); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT)); + assertFalse( + store.get(List.of("teams", "ns", "a"), "meta") + .value() + .containsKey("sessionMembership")); + + persist(leader, logs); + var wrongDomain = member("lead", "empty-journal", "alice", "agent", "session"); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "Display", List.of(wrongDomain)).block(WAIT)); + var wrongAgent = member("lead", "journal", "alice", "other-agent", "session"); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "Display", List.of(wrongAgent)).block(WAIT)); + assertThrows( + IllegalArgumentException.class, + () -> + service.findMembership( + new SessionKey("unknown", "alice", "session", "agent")) + .block(WAIT)); + assertEquals( + Status.ACTIVE, + service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT).status()); + } + + @Test + void nativeReadFailuresPropagateBeforeAdoptionWrites() { + var store = new InMemoryStore(); + var client = new LocalTeamClient(store); + var failure = new IllegalStateException("session backend unavailable"); + SessionLogStore unavailable = + (key, context) -> { + throw failure; + }; + var service = + client.sessionMembership("relations", Map.of(), Map.of("journal", unavailable)); + create(client, a); + var leader = member("lead", "journal", "alice", "agent", "session"); + assertEquals( + failure, + assertThrows( + IllegalStateException.class, + () -> + service.adoptTeam("alice", a, "Display", List.of(leader)) + .block(WAIT))); + assertFalse( + store.get(List.of("teams", "ns", "a"), "meta") + .value() + .containsKey("sessionMembership")); + assertNull(service.findMembership(leader.session()).block(WAIT)); + } + + @Test + void committedEventsWithoutAStateBaselineDoNotMakeASessionRegistrable() { + var store = new InMemoryStore(); + var client = new LocalTeamClient(store); + var service = client.sessionMembership("relations", Map.of(), Map.of("journal", logs)); + create(client, a); + var leader = member("lead", "journal", "alice", "agent", "session"); + var log = log(leader.session(), logs); + var writer = log.acquire("incomplete-initialization", Duration.ofSeconds(30)); + try { + log.commit( + writer, + "event-without-baseline", + 0, + List.of( + new SessionEvent( + 1, + "first-event", + 1, + 1, + "run/start", + "run", + "turn", + true, + "{}"))); + } finally { + log.release(writer); + } + assertEquals(1, log.head().seq()); + assertNull(SessionProjection.read(log).stateJson()); + assertThrows( + IllegalArgumentException.class, + () -> service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT)); + assertFalse( + store.get(List.of("teams", "ns", "a"), "meta") + .value() + .containsKey("sessionMembership")); + } + + @Test + void pendingNativeAdoptionRecoversAndReplayDoesNotRestoreRemovedAssociations() { + var failCommit = new AtomicBoolean(true); + var store = + new InMemoryStore() { + @Override + public boolean putIfVersion( + List ns, String key, Map value, long expected) { + if (ns.get(0).equals("team-session-membership") + && !((Map) value.get("receipts")).isEmpty() + && failCommit.compareAndSet(true, false)) { + throw new IllegalStateException("injected adoption commit failure"); + } + return super.putIfVersion(ns, key, value, expected); + } + }; + var client = new LocalTeamClient(store); + var service = client.sessionMembership("relations", Map.of(), Map.of("journal", logs)); + create(client, a); + var leader = member("lead", "journal", "alice", "agent", "session"); + persist(leader, logs); + long committedSeq = log(leader.session(), logs).head().seq(); + assertThrows( + IllegalStateException.class, + () -> service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT)); + var pending = service.getTeam("alice", a).block(WAIT); + assertEquals(Status.PENDING, pending.status()); + assertTrue(pending.memberships().isEmpty()); + assertNull(service.findMembership(leader.session()).block(WAIT)); + assertThrows( + IllegalStateException.class, () -> service.listMemberships("alice", a).block(WAIT)); + var declared = client.listMembers("ns", "a").block(WAIT); + assertEquals( + Set.of("lead", "worker"), + declared.stream().map(TeamMemberInfo::memberName).collect(Collectors.toSet())); + assertTrue(declared.stream().allMatch(m -> m.sessionId().isEmpty())); + + var reopened = + new LocalTeamClient(store) + .sessionMembership("relations", Map.of(), Map.of("journal", logs)); + var active = reopened.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT); + assertEquals(Status.ACTIVE, active.status()); + assertEquals(pending.teamId(), active.teamId()); + var binding = active.memberships().get(0); + assertEquals(binding, reopened.findMembership(leader.session()).block(WAIT)); + assertTrue( + reopened.unbindMember("alice", a, leader.session(), binding.token()).block(WAIT)); + var replay = reopened.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT); + assertEquals(active.teamId(), replay.teamId()); + assertTrue(replay.memberships().isEmpty()); + assertNull(reopened.findMembership(leader.session()).block(WAIT)); + assertEquals(committedSeq, log(leader.session(), logs).head().seq()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void schemaOneCannotSilentlyReinterpretNativeOwnerOrMarkerRecords(boolean corruptOwner) { + var store = new InMemoryStore(); + var client = new LocalTeamClient(store); + var service = client.sessionMembership("relations", Map.of(), Map.of("journal", logs)); + create(client, a); + var leader = member("lead", "journal", "alice", "agent", "session"); + persist(leader, logs); + var binding = + service.adoptTeam("alice", a, "Display", List.of(leader)) + .block(WAIT) + .memberships() + .get(0); + var metaNs = List.of("teams", "ns", "a"); + if (corruptOwner) { + var owner = new LinkedHashMap<>(store.get(RELATION_NS, "owner-YWxpY2U").value()); + owner.put("schema", 1); + store.put(RELATION_NS, "owner-YWxpY2U", owner); + } else { + var meta = new LinkedHashMap<>(store.get(metaNs, "meta").value()); + var marker = + new LinkedHashMap((Map) meta.get("sessionMembership")); + marker.put("schema", 1); + meta.put("sessionMembership", marker); + store.put(metaNs, "meta", meta); + } + var ownerBefore = store.get(RELATION_NS, "owner-YWxpY2U"); + var metaBefore = store.get(metaNs, "meta"); + assertThrows(IllegalStateException.class, () -> service.getTeam("alice", a).block(WAIT)); + assertThrows(IllegalStateException.class, () -> client.listMembers("ns", "a").block(WAIT)); + assertThrows( + IllegalStateException.class, + () -> service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT)); + assertThrows( + IllegalStateException.class, + () -> + service.unbindMember("alice", a, leader.session(), binding.token()) + .block(WAIT)); + assertEquals(ownerBefore, store.get(RELATION_NS, "owner-YWxpY2U")); + assertEquals(metaBefore, store.get(metaNs, "meta")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void independentClientsEnforceOneNativeMembershipAndReplayIdenticalBindings(boolean sameTeam) + throws Exception { + var racing = new AtomicBoolean(); + var writes = new AtomicInteger(); + var barrier = new CyclicBarrier(2); + var store = + new InMemoryStore() { + @Override + public boolean putIfVersion( + List ns, String key, Map value, long expected) { + if (racing.get() + && ns.get(0).equals("team-session-membership") + && writes.incrementAndGet() <= 2) { + try { + barrier.await(10, TimeUnit.SECONDS); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + return super.putIfVersion(ns, key, value, expected); + } + }; + var client = new LocalTeamClient(store); + var left = client.sessionMembership("relations", Map.of(), Map.of("journal", logs)); + var right = + new LocalTeamClient(store) + .sessionMembership("relations", Map.of(), Map.of("journal", logs)); + create(client, a); + create(client, b); + var firstLeader = member("lead", "journal", "alice", "leader", "one"); + var secondLeader = member("lead", "journal", "alice", "leader", "two"); + var worker = member("worker", "journal", "alice", "worker", "session"); + persist(firstLeader, logs); + persist(secondLeader, logs); + persist(worker, logs); + left.adoptTeam("alice", a, "Display", List.of(firstLeader)).block(WAIT); + right.adoptTeam("alice", b, "Display", List.of(secondLeader)).block(WAIT); + + racing.set(true); + var x = attempt(() -> left.bindMember("alice", a, worker).block(WAIT)); + var y = attempt(() -> right.bindMember("alice", sameTeam ? a : b, worker).block(WAIT)); + List outcomes = List.of(x.get(15, TimeUnit.SECONDS), y.get(15, TimeUnit.SECONDS)); + assertEquals( + sameTeam ? 2 : 1, outcomes.stream().filter(Membership.class::isInstance).count()); + assertEquals( + sameTeam ? 0 : 1, + outcomes.stream().filter(TeamConflictException.class::isInstance).count()); + Membership winner = left.findMembership(worker.session()).block(WAIT); + assertEquals(winner, right.findMembership(worker.session()).block(WAIT)); + if (sameTeam) assertEquals(outcomes.get(0), outcomes.get(1)); + assertEquals( + 1, + left.listMemberships("alice", a).block(WAIT).stream() + .filter(m -> m.member().session().equals(worker.session())) + .count() + + left.listMemberships("alice", b).block(WAIT).stream() + .filter(m -> m.member().session().equals(worker.session())) + .count()); + } + + @Test + void legacyJsonAndBindingsSurviveNativeSchemaUpgradeWithoutDowngrade() throws Exception { + var store = new InMemoryStore(); + var states = new InMemoryAgentStateStore(); + var client = new LocalTeamClient(store); + var service = + client.sessionMembership( + "relations", Map.of("legacy", states), Map.of("journal", logs)); + create(client, a); + create(client, b); + var oldKey = new SessionKey("legacy", "alice", "session"); + var oldLeader = + new MemberSession("lead", "definitions", "leader-definition", Role.LEADER, oldKey); + states.save("alice", "session", "example", new Saved("original")); + var oldBinding = + service.adoptTeam("alice", a, "Legacy", List.of(oldLeader)) + .block(WAIT) + .memberships() + .get(0); + var mapper = new ObjectMapper(); + String oldJson = + "{\"stateStoreDomain\":\"legacy\",\"owner\":\"alice\",\"sessionId\":\"session\"}"; + assertEquals(mapper.readTree(oldJson), mapper.readTree(mapper.writeValueAsString(oldKey))); + assertEquals(oldKey, mapper.readValue(oldJson, SessionKey.class)); + assertEquals(1, ownerSchema(store)); + + var nativeLeader = member("lead", "journal", "alice", "agent", "session"); + persist(nativeLeader, logs); + var nativeBinding = + service.adoptTeam("alice", b, "Native", List.of(nativeLeader)) + .block(WAIT) + .memberships() + .get(0); + assertEquals(2, ownerSchema(store)); + assertEquals(oldBinding, service.findMembership(oldKey).block(WAIT)); + assertNotEquals(oldBinding.teamId(), nativeBinding.teamId()); + assertEquals( + nativeLeader.session(), + mapper.readValue( + mapper.writeValueAsString(nativeLeader.session()), SessionKey.class)); + var legacyMarker = + (Map) + store.get(List.of("teams", "ns", "a"), "meta") + .value() + .get("sessionMembership"); + var nativeMarker = + (Map) + store.get(List.of("teams", "other-ns", "b"), "meta") + .value() + .get("sessionMembership"); + assertEquals(1, ((Number) legacyMarker.get("schema")).intValue()); + assertEquals(2, ((Number) nativeMarker.get("schema")).intValue()); + + var reopened = + new LocalTeamClient(store) + .sessionMembership( + "relations", Map.of("legacy", states), Map.of("journal", logs)); + assertTrue( + reopened.unbindMember("alice", b, nativeLeader.session(), nativeBinding.token()) + .block(WAIT)); + assertEquals(2, ownerSchema(store)); + assertEquals(oldBinding, reopened.findMembership(oldKey).block(WAIT)); + assertTrue(reopened.unbindMember("alice", a, oldKey, oldBinding.token()).block(WAIT)); + assertEquals(2, ownerSchema(store)); + assertEquals( + "original", + states.get("alice", "session", "example", Saved.class).orElseThrow().text()); + } + + @Test + void nativeWorkerCanJoinLegacyAdoptionWithoutChangingItsMarkerOrInitialBinding() { + var store = new InMemoryStore(); + var states = new InMemoryAgentStateStore(); + states.save("alice", "legacy-session", "example", new Saved("original")); + var client = new LocalTeamClient(store); + var service = + client.sessionMembership( + "relations", Map.of("legacy", states), Map.of("journal", logs)); + create(client, a); + var leader = + new MemberSession( + "lead", + "definitions", + "leader-definition", + Role.LEADER, + new SessionKey("legacy", "alice", "legacy-session")); + var legacyTeam = service.adoptTeam("alice", a, "Mixed", List.of(leader)).block(WAIT); + var metaBefore = store.get(List.of("teams", "ns", "a"), "meta"); + assertEquals(1, ownerSchema(store)); + var worker = member("worker", "journal", "alice", "runtime-worker", "native-session"); + persist(worker, logs); + var binding = service.bindMember("alice", a, worker).block(WAIT); + assertEquals(2, ownerSchema(store)); + assertEquals(metaBefore, store.get(List.of("teams", "ns", "a"), "meta")); + assertEquals(legacyTeam.teamId(), binding.teamId()); + assertEquals( + legacyTeam.memberships().get(0), + service.findMembership(leader.session()).block(WAIT)); + assertEquals(2, service.listMemberships("alice", a).block(WAIT).size()); + assertEquals( + "native-session", + client.listMembers("ns", "a").block(WAIT).stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + + var reopened = + new LocalTeamClient(store) + .sessionMembership( + "relations", Map.of("legacy", states), Map.of("journal", logs)); + assertEquals(binding, reopened.findMembership(worker.session()).block(WAIT)); + assertTrue( + reopened.unbindMember("alice", a, worker.session(), binding.token()).block(WAIT)); + assertEquals(2, ownerSchema(store)); + assertEquals( + legacyTeam, reopened.adoptTeam("alice", a, "Mixed", List.of(leader)).block(WAIT)); + assertEquals(metaBefore, store.get(List.of("teams", "ns", "a"), "meta")); + assertEquals( + "", + client.listMembers("ns", "a").block(WAIT).stream() + .filter(m -> m.memberName().equals("worker")) + .findFirst() + .orElseThrow() + .sessionId()); + assertNotNull(SessionProjection.read(log(worker.session(), logs)).stateJson()); + } + + private static int ownerSchema(InMemoryStore store) { + return ((Number) store.get(RELATION_NS, "owner-YWxpY2U").value().get("schema")).intValue(); + } + + private static void create(LocalTeamClient client, TeamAddress address) { + client.createTeam( + new TeamCreateSpec( + address.teamName(), + address.namespace(), + "objective", + "leader-definition", + "", + List.of( + new TeamMemberSpec( + "worker", "worker-definition", "", "byo")))) + .block(WAIT); + } + + private static MemberSession member( + String alias, String domain, String user, String agent, String session) { + return new MemberSession( + alias, + "definitions", + alias.equals("lead") ? "leader-definition" : "worker-definition", + alias.equals("lead") ? Role.LEADER : Role.WORKER, + new SessionKey(domain, user, session, agent)); + } + + private static SessionLog log(SessionKey session, SessionLogStore store) { + return store.open( + new io.agentscope.core.session.SessionKey( + session.owner(), session.agentId(), session.sessionId()), + RuntimeContext.builder() + .userId(session.owner()) + .sessionId(session.sessionId()) + .build()); + } + + private void persist(MemberSession member, SessionLogStore logs) { + Model model = + new Model() { + public String getModelName() { + return "baseline-fixture"; + } + + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.just( + ChatResponse.builder() + .content( + List.of( + TextBlock.builder() + .text("initialized") + .build())) + .build()); + } + }; + try (var agent = + HarnessAgent.builder() + .agentId(member.session().agentId()) + .workspace(workspace) + .model(model) + .sessionLogStore(logs) + .disableShellTool() + .disableFilesystemTools() + .disableWebTools() + .disableSubagents() + .disableDynamicSubagents() + .disableDynamicSkills() + .disableDefaultWorkspaceSkills() + .disableMemoryTools() + .disableMemoryHooks() + .disableToolsConfig() + .disableWorkspaceContext() + .disableAtPathExpansion() + .build()) { + agent.call( + "initialize", + RuntimeContext.builder() + .userId(member.session().owner()) + .sessionId(member.session().sessionId()) + .build()) + .block(WAIT); + } + } + + private static CompletableFuture attempt(Callable action) { + return CompletableFuture.supplyAsync( + () -> { + try { + return action.call(); + } catch (Exception failure) { + return failure; + } + }); + } + + record Saved(String text) implements State {} +} diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index aa0074f1c7..3e3d44f77b 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -48,7 +48,7 @@ Configure capabilities and use session operations as your application needs them `LocalTeamClient.sessionMembership(...)` explicitly enables adoption, binding, queries and safe removal for existing Leader and manually supplied **BYO** member Sessions. A definition's different Sessions may join different Teams; one exact Session may belong to only one Team in the configured relationship domain, including across Team namespaces. This programming API uses the existing `BaseStore` capability: `supportsAtomicSessionStorage()` must return `true`, with positive item versions and atomic `putIfVersion` across writers sharing the backend. A failed comparison writes nothing; storage errors must propagate. The existing `InMemoryStore` and JDBC `JdbcStore` declare this capability. Custom providers use the same contract; its default remains `false`. Other `BaseStore` and `TeamClient` implementations continue to work through their existing APIs. -Create the Team through the existing `createTeam`, then adopt it with an existing persisted Leader Session. Bind only members already declared in that Team's directory. The example uses `.block()` at an application entry point; compose the returned `Mono` operations instead on reactive request paths. +Create the Team through the existing `createTeam`, then adopt it with an existing persisted Leader Session. Bind only members already declared in that Team's directory. The following example uses legacy `AgentStateStore` Sessions; native Session logs are supported through the configuration below. The example uses `.block()` at an application entry point; compose the returned `Mono` operations instead on reactive request paths. ```java import io.agentscope.core.state.AgentState; @@ -92,14 +92,41 @@ boolean removed = membership.unbindMember("alice", address, worker.session(), bi boolean stateKept = stateStore.exists("alice", "worker-session"); ``` -The in-memory example demonstrates the operations without models or Worker execution. For process-restart persistence, configure the existing JDBC `JdbcStore` with a durable database and use the same relationship domain and Session-store domain mapping on every application instance. Membership does not write or delete `AgentStateStore` state; a file or JDBC state store can retain the existing conversations independently. The configured stores here are `AgentStateStore` backends; this API does not automatically discover native `AgentSession`/`SessionLog` Sessions or map their `(userId, agentId, sessionId)` identities. +The in-memory example demonstrates the operations without models or Worker execution. For process-restart persistence, configure the existing JDBC `JdbcStore` with a durable database and use the same relationship domain and Session-store domain mapping on every application instance. Membership does not write or delete Session state or logs; their configured backends retain existing conversations independently. + +### Existing native Sessions + +For native history, use the three-argument `sessionMembership` overload with the actual `SessionLogStore`. Replace the legacy store configuration and member keys in the example above with the following configuration; the `createTeam`, `adoptTeam`, binding, query and removal operations stay the same. Here, both existing agents use `logs` through `.sessionLogStore(logs)`, and their contexts identify already initialized Sessions owned by `alice`. + +```java +var membership = client.sessionMembership( + "app-relations", Map.of(), Map.of("native-logs", logs)); +var leader = new MemberSession("lead", "alice", "leader-agent", Role.LEADER, + SessionKey.nativeSession("native-logs", leaderAgent.sessionKey(leaderContext))); +var worker = new MemberSession("researcher", "alice", "research-agent", Role.WORKER, + SessionKey.nativeSession("native-logs", workerAgent.sessionKey(workerContext))); +``` + +Native registration reads the committed Session log baseline. Initialize the Session through a normal agent call or an `AgentSession.submit` task before adoption or binding. Opening a Session handle or accepting a queued task alone does not establish a committed baseline. A missing baseline rejects registration, and log read errors propagate; membership does not write an `AgentState` placeholder or repair the log. Queries and token-based removal remain available if the log later becomes unavailable. + +The [NativeTeamMembershipExample](https://github.com/agentscope-ai/agentscope-java/blob/main/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java) runs two real native conversations with an offline scripted model, registers them through this programming API, reopens the clients and log backend, and removes one association while checking that its Session log remains. Run it from the repository root: + +```bash +mvn -B -T1C -pl agentscope-examples/documentation -am install -DskipTests +mvn -B -pl agentscope-examples/documentation exec:exec \ + -Dexec.executable=java \ + -Dexec.args="-classpath %classpath io.agentscope.examples.documentation2.harness.teams.NativeTeamMembershipExample" +``` + +The example requires no provider credentials. It stores Session logs in a temporary workspace and reopens them in the same process. Its Team records use `InMemoryStore`, so it does not retain associations after process exit; use a durable `JdbcStore` for that requirement. ### Identity and ownership - `TeamAddress(namespace, teamName)` is the existing storage/routing address. Supply it explicitly; adoption rejects null/blank namespaces instead of silently moving historical records. Nonblank strings are preserved. Logical `teamId` and `displayName` are separate: two Teams may have the same display name when created at distinct addresses. One address can be adopted by only one owner and relationship domain. -- `SessionKey(stateStoreDomain, owner, sessionId)` identifies the actual Session slot. Use the same stable domain ID for the same physical store partition, and different IDs for independent partitions. Assigning two IDs to the same backing store bypasses this identity contract. Provider-specific aliases must also be resolved by the application; Agent references do not establish storage isolation. -- Session owner must equal Team owner. Definition owner is recorded separately and may differ; this trusted application API does not establish definition access or invitation authorization. Session existence means any persisted state, including legacy keys. A Session with no persisted state cannot be registered. -- Null owner supports anonymous Sessions. Blank owners and the reserved `__anon__` literal are rejected by this new API to avoid aliasing existing anonymous state slots. Old APIs retain their accepted inputs. IDs and addresses reject blank values and control characters. +- `SessionKey(stateStoreDomain, owner, sessionId)` retains the legacy state-slot identity and JSON shape. The four-argument form adds the running `agentId`; `nativeSession(logDomain, coreSessionKey)` preserves the native `(userId, agentId, sessionId)` identity. Running `agentId` and the declared definition's `agentRef` are independent. Native Sessions differing in user, agent, session or actual storage domain are distinct. +- A domain ID identifies the actual backend and partition routing, independently of the relationship domain or Team namespace. Use the same stable ID for the same physical partition on every client, and different IDs for independent partitions. Legacy and native domain names must not overlap. Assigning two IDs to one backing partition bypasses Session uniqueness. The application must resolve provider aliases and, when extra tenant attributes select storage, configure a trusted adapter that fixes those attributes to the declared partition. Membership supplies the Session's user and session IDs when opening its log; it does not infer additional routing context. +- Session owner must equal Team owner. Definition owner is recorded separately and may differ; this trusted application API does not establish definition access or invitation authorization. Legacy existence accepts persisted state, including old state keys; native existence requires a committed log baseline. Neither check grants access to a definition. +- Null owner supports anonymous Sessions. Empty or blank owners, control characters and the reserved `__anon__` literal are rejected by this API. Use a supported identity consistently from Session initialization onward; converting an existing empty user ID to null can select a different Session on some backends. Old APIs retain their accepted inputs. IDs and addresses reject blank values and control characters. ### Queries, retries and compatibility @@ -115,11 +142,11 @@ For adopted Teams, legacy `listMembers` reads the same associations and projects Before adopting a Team, stop its older-version and unenabled writers and wait for their in-flight writes to finish. Upgrade all participating writers and enable protection on each client with `sessionMembership(...)`. Enabling a client does not stop writes that already entered its legacy path. Older binaries do not understand the relationship projection, and their unconditional completion fallback can erase a concurrent adoption marker. Mixed-version writes to adopted Teams are not supported. For rollback, use a version that understands the stored format, or stop all writers and restore a backup taken before adoption. -Relationship schema 1 reads earlier BYO records containing `source: "BYO"` and draft records that omitted the field. Missing source means manually supplied existing resources; it does not grant ownership for deletion. New relationship writes retain BYO in the internal storage format independently of the public `Membership` record. Unknown sources, fields or schema versions are rejected without resetting or overwriting the record. +Purely legacy relationships retain schema 1 and read earlier BYO records containing `source: "BYO"` and draft records that omitted the field. Native identities use schema 2; an owner item upgraded to schema 2 does not downgrade after native associations are removed. Before registering native Sessions, stop and drain older writers and upgrade every participating client to a version that understands schema 2. Older readers cannot read native records. Missing source means manually supplied existing resources; it does not grant ownership for deletion. New relationship writes retain BYO in the internal storage format independently of the public `Membership` record. Unknown sources, fields or schema versions are rejected without resetting or overwriting the record. Team metadata, phase and declared definitions remain in their existing records. Only relationship links and adoption receipts are kept in one item per relationship domain and owner; read/write cost and contention grow with that owner's links. All writers to adopted records must honor the versioned capability; direct raw mutation or deletion is unsupported. Malformed or unknown relationship formats fail rather than being reset. Session existence validation and relationship commit are separate operations, so external Session deletion can leave an association that the application must explicitly remove. -Unbinding preserves the Agent definition, Session state and workspace. It does **not** stop runs, revoke tools, update a constructed static `TeamContext`, or unregister `bindSession` notifications. Task boards, mailboxes and static `teamsMode` keep their existing runtime behavior. This capability does not create models, Workers, invitations, schedules or Service Team/Run resources. +Unbinding preserves the Agent definition, Session state, native logs and workspace. It does **not** stop runs, revoke tools, update a constructed static `TeamContext`, or unregister `bindSession` notifications. Task boards, mailboxes and static `teamsMode` keep their existing runtime behavior. This capability does not create models, Workers, invitations, schedules or Service Team/Run resources. ## How state flows diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index c25e468515..3be23d5b34 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -48,7 +48,7 @@ en_link: /v2/en/docs/harness/architecture `LocalTeamClient.sessionMembership(...)` 显式启用既有 Leader 和手工提供的 **BYO** 成员 Session 的接入、登记、查询与安全解除。同一定义的不同 Session 可以加入不同 Team;同一完整身份的 Session 在配置的关系域中只能属于一个 Team,包括跨 Team namespace 的情况。这是编程 API,复用已有 `BaseStore` 能力:`supportsAtomicSessionStorage()` 必须返回 `true`,提供正数记录版本,以及共享后端所有写入者之间原子的 `putIfVersion`。比较失败不得写入,真实存储错误必须传播。已有 `InMemoryStore` 和 JDBC `JdbcStore` 已声明该能力;自定义 provider 使用相同合同,默认仍为 `false`。其他 `BaseStore` 和 `TeamClient` 仍可通过原有 API 使用。 -先通过已有 `createTeam` 建立 Team,再用已持久化的 Leader Session 显式接入。只能绑定已在该 Team 目录中声明的成员。下面在应用入口使用 `.block()`;响应式请求路径应组合返回的 `Mono`。 +先通过已有 `createTeam` 建立 Team,再用已持久化的 Leader Session 显式接入。只能绑定已在该 Team 目录中声明的成员。下面示例使用旧 `AgentStateStore` Session;原生 Session 日志使用后文的配置。示例在应用入口使用 `.block()`;响应式请求路径应组合返回的 `Mono`。 ```java import io.agentscope.core.state.AgentState; @@ -92,14 +92,41 @@ boolean removed = membership.unbindMember("alice", address, worker.session(), bi boolean stateKept = stateStore.exists("alice", "worker-session"); ``` -内存示例不需要模型或 Worker 运行,展示的是实际归属操作。需要跨进程重启持久保存时,使用已有 JDBC `JdbcStore` 与持久数据库,并在所有应用实例使用相同关系域和 Session 存储域映射。归属操作不写入或删除 `AgentStateStore` 状态;文件或 JDBC 状态存储可以独立保留已有对话。这里配置的是 `AgentStateStore` 后端;本 API 不会自动发现原生 `AgentSession`/`SessionLog` 会话,也不会自动映射其 `(userId, agentId, sessionId)` 身份。 +内存示例不需要模型或 Worker 运行,展示的是实际归属操作。需要跨进程重启持久保存时,使用已有 JDBC `JdbcStore` 与持久数据库,并在所有应用实例使用相同关系域和 Session 存储域映射。归属操作不写入或删除 Session 状态及日志;其配置的后端独立保留已有对话。 + +### 既有原生 Session + +原生历史使用三参数 `sessionMembership` 重载,传入实际的 `SessionLogStore`。将上例中的旧状态存储配置和成员身份替换为下面配置,`createTeam`、`adoptTeam`、绑定、查询及解除操作保持一致。这里两个既有 Agent 均通过 `.sessionLogStore(logs)` 使用同一个 `logs`,各自的上下文指向已完成初始化、owner 为 `alice` 的 Session。 + +```java +var membership = client.sessionMembership( + "app-relations", Map.of(), Map.of("native-logs", logs)); +var leader = new MemberSession("lead", "alice", "leader-agent", Role.LEADER, + SessionKey.nativeSession("native-logs", leaderAgent.sessionKey(leaderContext))); +var worker = new MemberSession("researcher", "alice", "research-agent", Role.WORKER, + SessionKey.nativeSession("native-logs", workerAgent.sessionKey(workerContext))); +``` + +原生登记读取 Session 日志中已提交的 baseline。接入或绑定之前,应通过普通 Agent 调用或 `AgentSession.submit` 任务初始化 Session。仅打开 Session 句柄或接受排队任务,不表示 baseline 已提交。缺少 baseline 时拒绝登记,日志读取错误直接传播;归属操作不写入占位 `AgentState`,也不修复日志。日志后来不可用时,仍可查询归属和使用 token 解除关联。 + +[NativeTeamMembershipExample](https://github.com/agentscope-ai/agentscope-java/blob/main/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java) 通过离线脚本模型执行两个真实原生会话,再使用编程 API 登记、重开客户端及日志后端,最后解除一个关联并检查其 Session 日志仍然保留。在仓库根目录运行: + +```bash +mvn -B -T1C -pl agentscope-examples/documentation -am install -DskipTests +mvn -B -pl agentscope-examples/documentation exec:exec \ + -Dexec.executable=java \ + -Dexec.args="-classpath %classpath io.agentscope.examples.documentation2.harness.teams.NativeTeamMembershipExample" +``` + +示例不需要模型服务凭证,Session 日志保存在临时 workspace 中,并在同一进程内重开。示例的 Team 记录使用 `InMemoryStore`,进程退出后不会保留归属;需要跨进程恢复时,应使用持久化 `JdbcStore`。 ### 身份与所有权 - `TeamAddress(namespace, teamName)` 是已有存储/路由地址。接入时显式提供,拒绝 null/空白 namespace,避免静默迁移历史记录;非空白字符串原样保留。逻辑 `teamId` 和 `displayName` 独立;不同地址的 Team 可以使用相同展示名。同一地址只能由一个 owner 和关系域接入。 -- `SessionKey(stateStoreDomain, owner, sessionId)` 指向真实 Session 存储槽。同一物理存储分区使用相同稳定域 ID,独立分区使用不同 ID。将同一底层存储配置成两个域 ID 会绕过这一身份合同。应用也须处理 provider 的别名;Agent 引用不能证明状态隔离。 -- Session owner 必须等于 Team owner。定义 owner 独立记录,可以不同;可信应用编程 API 不提供定义访问或邀请授权。存在校验接受任何已持久化状态,包括旧 state keys;没有持久状态的 Session 不能登记。 -- null owner 支持匿名 Session。新 API 拒绝空白 owner 和保留字面值 `__anon__`,避免与已有匿名状态槽混同;旧 API 的已接受输入不变。标识和地址拒绝空白值及控制字符。 +- `SessionKey(stateStoreDomain, owner, sessionId)` 保留旧状态槽身份及 JSON 形状。四参数形式增加运行 `agentId`;`nativeSession(logDomain, coreSessionKey)` 保留原生 `(userId, agentId, sessionId)` 身份。运行 `agentId` 与声明定义的 `agentRef` 独立。用户、Agent、Session 或实际存储域不同的原生会话,是不同的 Session。 +- 域 ID 标识实际后端及分区路由,与关系域、Team namespace 分开。同一物理分区在所有客户端使用相同稳定 ID,独立分区使用不同 ID;legacy 与 native 域名不能重叠。将同一底层分区配置成两个域 ID 会绕过 Session 唯一性。应用须处理 provider 别名;额外 tenant 属性参与存储路由时,应配置可信 adapter,将这些属性固定到声明的分区。归属操作打开日志时传入 Session 的 user 和 session ID,不推断额外路由上下文。 +- Session owner 必须等于 Team owner。定义 owner 独立记录,可以不同;可信应用编程 API 不提供定义访问或邀请授权。旧存储的存在校验接受已持久化状态,包括旧 state keys;原生存在校验要求已提交的日志 baseline。两种校验都不授予定义访问权限。 +- null owner 支持匿名 Session。该 API 拒绝空串、空白 owner、控制字符和保留字面值 `__anon__`。从 Session 初始化起就应一致使用受支持的身份;将已有空 user ID 改成 null,在某些后端可能指向不同 Session。旧 API 的已接受输入不变。标识和地址拒绝空白值及控制字符。 ### 查询、重试与兼容 @@ -115,11 +142,11 @@ boolean stateKept = stateStore.exists("alice", "worker-session"); 接入 Team 前,先停止该 Team 的旧版本及未启用写入方,等待其在途写操作结束,再升级所有参与写入的实例,并在每个 client 上调用 `sessionMembership(...)` 启用保护。启用 client 不会停止已经进入旧路径的写操作。旧二进制不理解归属投影,其无条件完成 fallback 可能清掉并发接入标记。已接入 Team 不支持新旧版本混写。回滚需使用理解现有存储格式的版本,或停止全部写入方后恢复接入前的备份。 -关系 schema 1 可以读取早期包含 `source: "BYO"` 的记录,以及曾省略该字段的草案记录。缺省来源只表示手工提供的既有资源,不授予删除权。新关系写入在内部存储格式中保留 BYO,与公开 `Membership` record 分开。未知来源、字段或格式版本明确拒绝,不重置或覆盖原记录。 +纯 legacy 关系保留 schema 1,可以读取早期包含 `source: "BYO"` 的记录,以及曾省略该字段的草案记录。原生身份使用 schema 2;owner 记录升级到 schema 2 后,即使解除全部原生关联也不降级。登记原生 Session 前,须停止并排空旧写入者,将所有参与客户端升级到理解 schema 2 的版本;旧读取器无法读取原生记录。缺省来源只表示手工提供的既有资源,不授予删除权。新关系写入在内部存储格式中保留 BYO,与公开 `Membership` record 分开。未知来源、字段或格式版本明确拒绝,不重置或覆盖原记录。 Team 元数据、phase 和声明定义继续使用原记录;每个关系域、每个 owner 的单条记录只保存关联和接入回执,读写成本与竞争随该 owner 的关联数增加。已接入记录的所有写入者须遵守版本能力;不支持直接原始修改或删除。损坏或未知关系格式会报错,不重置为空。Session 存在校验与关系提交是独立操作,外部删除 Session 可能留下关联,应用须显式解除。 -解除保留 Agent 定义、Session 状态和 workspace,不停止运行、撤销工具权限、更新已构造的静态 `TeamContext` 或注销 `bindSession` 通知。任务板、mailbox 和静态 `teamsMode` 保留原运行语义。该能力不创建模型、Worker、邀请、调度或 Service Team/Run 资源。 +解除保留 Agent 定义、Session 状态、原生日志和 workspace,不停止运行、撤销工具权限、更新已构造的静态 `TeamContext` 或注销 `bindSession` 通知。任务板、mailbox 和静态 `teamsMode` 保留原运行语义。该能力不创建模型、Worker、邀请、调度或 Service Team/Run 资源。 ## 状态怎么流转 From 324d7400aafab0c77d92b0224b47cf7b5b722c63 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Sat, 24 Oct 2026 15:44:02 +0800 Subject: [PATCH 13/13] test(harness): cover membership concurrency and roster output --- .../harness/agent/team/LocalTeamClient.java | 3 + .../team/NativeTeamSessionMembershipTest.java | 60 +++++++++++++ .../agent/team/TeamSessionMembershipTest.java | 86 ++++++++++++++++++- docs/v2/en/docs/harness/architecture.md | 14 +-- docs/v2/zh/docs/harness/architecture.md | 14 +-- 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java index db76e3cf19..51015019c8 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/team/LocalTeamClient.java @@ -73,6 +73,9 @@ public TeamSessionMembership sessionMembership( * receive the member's user/session context; bind any additional routing attributes in a * configured store adapter. Upgrade and drain older writers before storing native records. * + *

A successful call also enables strict completion CAS on this client, including Teams + * awaiting adoption. Enable every writer before adopting Teams. + * * @param relationDomain shared relationship authority domain * @param stateStores legacy state domains; existing mappings retain their meaning * @param sessionLogs native log domains, disjoint from legacy domains diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java index 582d419caf..1e75d54d13 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/NativeTeamSessionMembershipTest.java @@ -15,6 +15,7 @@ */ package io.agentscope.harness.agent.team; +import static io.agentscope.harness.agent.tool.ToolResultAssertions.assertText; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -27,6 +28,7 @@ import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultState; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; @@ -49,6 +51,7 @@ import io.agentscope.harness.agent.team.TeamSessionMembership.SessionKey; import io.agentscope.harness.agent.team.TeamSessionMembership.Status; import io.agentscope.harness.agent.team.TeamSessionMembership.TeamAddress; +import io.agentscope.harness.agent.tool.TeamTool; import java.nio.file.Path; import java.time.Duration; import java.util.HashSet; @@ -78,6 +81,63 @@ class NativeTeamSessionMembershipTest { private final TeamAddress a = new TeamAddress("ns", "a"); private final TeamAddress b = new TeamAddress("other-ns", "b"); + @Test + void memberToolProjectsNativeSessionIdsWithoutExposingRelationshipRecords() throws Exception { + var client = new LocalTeamClient(new InMemoryStore()); + create(client, a); + var service = client.sessionMembership("relations", Map.of(), Map.of("journal", logs)); + MemberSession leader = + member("lead", "journal", "alice", "runtime-leader", "leader-session"); + MemberSession worker = + member("worker", "journal", "alice", "runtime-worker", "worker-session"); + persist(leader, logs); + persist(worker, logs); + service.adoptTeam("alice", a, "Display", List.of(leader)).block(WAIT); + Membership binding = service.bindMember("alice", a, worker).block(WAIT); + var tool = + new TeamTool( + client, + new TeamContext( + a.teamName(), + a.namespace(), + "objective", + "lead", + true, + List.of(), + List.of("listMembers"))); + var json = new ObjectMapper(); + + for (boolean bound : List.of(true, false)) { + if (!bound) { + assertTrue( + service.unbindMember("alice", a, worker.session(), binding.token()) + .block(WAIT)); + } + var rows = json.readTree(assertText(tool.listMembers(), ToolResultState.SUCCESS)); + assertTrue(rows.isArray()); + assertEquals(2, rows.size()); + var sessions = new LinkedHashMap(); + for (var row : rows) { + var fields = new HashSet(); + row.fieldNames().forEachRemaining(fields::add); + assertEquals( + Set.of( + "memberName", + "agentRef", + "phase", + "sessionId", + "deployMode", + "isLead"), + fields); + sessions.put(row.get("memberName").asText(), row.get("sessionId").asText()); + } + assertEquals( + Map.of("lead", "leader-session", "worker", bound ? "worker-session" : ""), + sessions); + } + assertNotNull(SessionProjection.read(log(worker.session(), logs)).stateJson()); + } + @Test void existingNativeSessionsSupportAdoptionBindingQueriesAndTokenSafeRemoval() { var store = new InMemoryStore(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java index 4c09d16eaa..64f6d439f7 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/team/TeamSessionMembershipTest.java @@ -335,6 +335,12 @@ void ownerAndActualStateDomainsDistinguishIdenticalSessionStrings() { service.adoptTeam("bob", c, "a", List.of(other)).block(); assertEquals("bob", service.findMembership(other.session()).block().teamOwner()); assertThrows(TeamConflictException.class, () -> service.getTeam("bob", a).block()); + assertThrows( + TeamConflictException.class, + () -> + client.sessionMembership("other-app", Map.of("state", states)) + .getTeam("alice", a) + .block()); assertThrows( TeamConflictException.class, () -> @@ -566,6 +572,78 @@ void unbindRereadsAfterConflictAndPreservesReplacementBinding() { assertEquals(replacement.get(), service.findMembership(worker.session()).block()); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void concurrentBindAndUnbindAcrossTeamsPreserveBothChanges(boolean unbindRetries) { + CountingStore counting = useCountingStore(); + MemberSession leadA = member(states, "alice", "lead", "leader-a", "leader-agent"); + MemberSession leadB = member(states, "alice", "lead", "leader-b", "leader-agent"); + MemberSession workerA = member(states, "alice", "worker", "worker-a", "worker-agent"); + MemberSession workerB = member(states, "alice", "worker", "worker-b", "worker-agent"); + TeamView teamA = service.adoptTeam("alice", a, "a", List.of(leadA, workerA)).block(); + TeamView teamB = service.adoptTeam("alice", b, "b", List.of(leadB)).block(); + Membership leaderA = service.findMembership(leadA.session()).block(); + Membership leaderB = service.findMembership(leadB.session()).block(); + Membership original = service.findMembership(workerA.session()).block(); + TeamSessionMembership other = + new LocalTeamClient(counting).sessionMembership("app", Map.of("state", states)); + AtomicReference added = new AtomicReference<>(); + + if (unbindRetries) { + counting.beforeOwnerCas.set( + () -> added.set(other.bindMember("alice", b, workerB).block())); + assertTrue( + service.unbindMember("alice", a, workerA.session(), original.token()).block()); + } else { + counting.beforeOwnerCas.set( + () -> + assertTrue( + other.unbindMember( + "alice", a, workerA.session(), original.token()) + .block())); + added.set(service.bindMember("alice", b, workerB).block()); + } + + TeamSessionMembership reopened = + new LocalTeamClient(counting).sessionMembership("app", Map.of("state", states)); + assertNull(reopened.findMembership(workerA.session()).block()); + assertNotNull(added.get()); + assertEquals(added.get(), reopened.findMembership(workerB.session()).block()); + assertEquals(List.of(leaderA), reopened.listMemberships("alice", a).block()); + assertEquals(leaderB, reopened.findMembership(leadB.session()).block()); + assertEquals(2, reopened.listMemberships("alice", b).block().size()); + assertEquals(teamA.teamId(), reopened.getTeam("alice", a).block().teamId()); + assertEquals(teamB.teamId(), reopened.getTeam("alice", b).block().teamId()); + assertEquals( + List.of(leaderA), + reopened.adoptTeam("alice", a, "a", List.of(leadA, workerA)).block().memberships()); + } + + @Test + void absentUnbindCanPrecedeOverlappingBindWithoutRemovingItsNewToken() { + CountingStore counting = useCountingStore(); + MemberSession lead = member(states, "alice", "lead", "one", "leader-agent"); + MemberSession worker = member(states, "alice", "worker", "worker", "worker-agent"); + service.adoptTeam("alice", a, "a", List.of(lead)).block(); + Membership original = service.bindMember("alice", a, worker).block(); + assertTrue(service.unbindMember("alice", a, worker.session(), original.token()).block()); + TeamSessionMembership other = + new LocalTeamClient(counting).sessionMembership("app", Map.of("state", states)); + AtomicReference replacement = new AtomicReference<>(); + counting.afterOwnerRead.set( + () -> replacement.set(other.bindMember("alice", a, worker).block())); + + assertFalse(service.unbindMember("alice", a, worker.session(), original.token()).block()); + + assertNotNull(replacement.get()); + assertNotEquals(original.token(), replacement.get().token()); + assertEquals(replacement.get(), service.findMembership(worker.session()).block()); + assertThrows( + TeamConflictException.class, + () -> service.unbindMember("alice", a, worker.session(), original.token()).block()); + assertEquals(replacement.get(), service.findMembership(worker.session()).block()); + } + @Test void ownerInitializationContentionDoesNotAdoptTeamOrBlockLegacyQueries() { FaultStore faulty = useFaultStore(); @@ -1290,13 +1368,19 @@ public boolean putIfVersion( private static class CountingStore extends InMemoryStore { final AtomicInteger ownerReads = new AtomicInteger(); final AtomicReference beforeOwnerCas = new AtomicReference<>(); + final AtomicReference afterOwnerRead = new AtomicReference<>(); @Override public StoreItem get(List ns, String key) { + StoreItem snapshot = super.get(ns, key); if (ns.get(0).equals("team-session-membership")) { ownerReads.incrementAndGet(); + Runnable interleave = afterOwnerRead.getAndSet(null); + if (interleave != null) { + interleave.run(); + } } - return super.get(ns, key); + return snapshot; } @Override diff --git a/docs/v2/en/docs/harness/architecture.md b/docs/v2/en/docs/harness/architecture.md index 3e3d44f77b..57477f7384 100644 --- a/docs/v2/en/docs/harness/architecture.md +++ b/docs/v2/en/docs/harness/architecture.md @@ -107,7 +107,7 @@ var worker = new MemberSession("researcher", "alice", "research-agent", Role.WOR SessionKey.nativeSession("native-logs", workerAgent.sessionKey(workerContext))); ``` -Native registration reads the committed Session log baseline. Initialize the Session through a normal agent call or an `AgentSession.submit` task before adoption or binding. Opening a Session handle or accepting a queued task alone does not establish a committed baseline. A missing baseline rejects registration, and log read errors propagate; membership does not write an `AgentState` placeholder or repair the log. Queries and token-based removal remain available if the log later becomes unavailable. +Native registration reads the committed Session log baseline. Each existence check scans the committed log history, so adoption and binding cost grows with conversation length. Initialize the Session through a normal agent call or an `AgentSession.submit` task before adoption or binding. Opening a Session handle or accepting a queued task alone does not establish a committed baseline. A missing baseline rejects registration, and log read errors propagate; membership does not write an `AgentState` placeholder or repair the log. Queries and token-based removal remain available if the log later becomes unavailable. The [NativeTeamMembershipExample](https://github.com/agentscope-ai/agentscope-java/blob/main/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java) runs two real native conversations with an offline scripted model, registers them through this programming API, reopens the clients and log backend, and removes one association while checking that its Session log remains. Run it from the repository root: @@ -122,7 +122,7 @@ The example requires no provider credentials. It stores Session logs in a tempor ### Identity and ownership -- `TeamAddress(namespace, teamName)` is the existing storage/routing address. Supply it explicitly; adoption rejects null/blank namespaces instead of silently moving historical records. Nonblank strings are preserved. Logical `teamId` and `displayName` are separate: two Teams may have the same display name when created at distinct addresses. One address can be adopted by only one owner and relationship domain. +- `TeamAddress(namespace, teamName)` is the existing storage/routing address. Supply it explicitly; adoption rejects null/blank namespaces instead of silently moving historical records. If `createTeam` received a null namespace, it stored the Team under `"default"`; use that namespace (available in the returned `TeamInfo`) when constructing the adoption address. Nonblank strings are preserved. Logical `teamId` and `displayName` are separate: two Teams may have the same display name when created at distinct addresses. One address can be adopted by only one owner and relationship domain. - `SessionKey(stateStoreDomain, owner, sessionId)` retains the legacy state-slot identity and JSON shape. The four-argument form adds the running `agentId`; `nativeSession(logDomain, coreSessionKey)` preserves the native `(userId, agentId, sessionId)` identity. Running `agentId` and the declared definition's `agentRef` are independent. Native Sessions differing in user, agent, session or actual storage domain are distinct. - A domain ID identifies the actual backend and partition routing, independently of the relationship domain or Team namespace. Use the same stable ID for the same physical partition on every client, and different IDs for independent partitions. Legacy and native domain names must not overlap. Assigning two IDs to one backing partition bypasses Session uniqueness. The application must resolve provider aliases and, when extra tenant attributes select storage, configure a trusted adapter that fixes those attributes to the declared partition. Membership supplies the Session's user and session IDs when opening its log; it does not infer additional routing context. - Session owner must equal Team owner. Definition owner is recorded separately and may differ; this trusted application API does not establish definition access or invitation authorization. Legacy existence accepts persisted state, including old state keys; native existence requires a committed log baseline. Neither check grants access to a definition. @@ -136,15 +136,19 @@ Before writing an adoption marker, adoption checks that no initial Session is al Adoption then writes a marker to the existing Team meta and atomically commits initial associations and a receipt in one owner item. A failure or a concurrent Session claim between these writes can leave `PENDING`: `getTeam` reports it with no committed members, and `listMemberships` fails explicitly. Legacy `listMembers` and `broadcastMessage` continue using the original declared directory, including its original Session fields; this does not report committed associations. After commit, `listMembers` switches to the association projection. Retry the same complete `adoptTeam` request, including display name and initial members. A conflicting initial request cannot overwrite that marker. Replaying an already committed adoption returns the current associations and does not restore subsequently removed members. -An identical bind retains its token. Unbinding requires the expected token and returns `true` when removed, or `false` when already absent. A wrong Team or stale token fails; an actual unbind followed by a new bind produces a new token. CAS contention retries at most ten times, then raises `TeamConflictException`; storage errors propagate without unconditional-write fallback. If the response fails after a commit, query or retry the identical request to determine the outcome. Cancellation does not roll back a committed association. +Recovery requires the original initial Sessions to remain available and eligible for association. After a transient storage failure, restore backend availability and retry the original request. If another Team has claimed a Session, resolve that association through the authorized application workflow before retrying. A deleted Session must be restored as the original persisted Session through its supported storage/recovery path; creating placeholder state is not recovery. If those conditions cannot be restored, the Team remains `PENDING`: there is currently no supported cancel/abandon API. Do not delete or rewrite its marker or owner record to bypass this state. -For adopted Teams, legacy `listMembers` reads the same associations and projects `sessionId`; an unbound declared member has an empty `sessionId`. The old record shape, declared `isLead`, `phase` and `deployMode` remain unchanged. Existing nonempty legacy Session fields require explicit full identities in the initial adoption request. `completeTeam` still changes only the original phase to `Completed`, preserving associations and the adoption marker. A successful `sessionMembership(...)` call enables strict completion CAS on that client, including before adoption. Completion also uses strict CAS when a new client observes an adoption marker; persistent contention raises a conflict instead of overwriting another writer. Unenabled clients retain the old completion fallback for unadopted Teams, including on versioned stores. +An identical bind retains its token. Unbinding requires the expected token and returns `true` when removed, or `false` when already absent. When a binding is present, a wrong Team or stale token fails; an actual unbind followed by a new bind produces a new token. A `false` result describes absence observed during the operation, not a guarantee that the Session is still unbound when the call returns: an overlapping bind can complete afterward. Such a new binding is preserved. CAS contention retries at most ten times, then raises `TeamConflictException`; storage errors propagate without unconditional-write fallback. If the response fails after a commit, query or retry the identical request to determine the outcome. Cancellation does not roll back a committed association. + +For adopted Teams, legacy `listMembers` reads the same associations and projects `sessionId`; an unbound declared member has an empty `sessionId`. The old record shape, declared `isLead`, `phase` and `deployMode` remain unchanged. The existing `TeamTool.listMembers` serializes this roster, so its model-visible `sessionId` now reflects the associated Session (or an empty value after unbinding). It does not include binding tokens, relationship records or the full native storage identity. Existing nonempty legacy Session fields require explicit full identities in the initial adoption request. `completeTeam` still changes only the original phase to `Completed`, preserving associations and the adoption marker. A successful `sessionMembership(...)` call enables strict completion CAS on that client, including before adoption. Completion also uses strict CAS when a new client observes an adoption marker; persistent contention raises a conflict instead of overwriting another writer. Unenabled clients retain the old completion fallback for unadopted Teams, including on versioned stores. Before adopting a Team, stop its older-version and unenabled writers and wait for their in-flight writes to finish. Upgrade all participating writers and enable protection on each client with `sessionMembership(...)`. Enabling a client does not stop writes that already entered its legacy path. Older binaries do not understand the relationship projection, and their unconditional completion fallback can erase a concurrent adoption marker. Mixed-version writes to adopted Teams are not supported. For rollback, use a version that understands the stored format, or stop all writers and restore a backup taken before adoption. Purely legacy relationships retain schema 1 and read earlier BYO records containing `source: "BYO"` and draft records that omitted the field. Native identities use schema 2; an owner item upgraded to schema 2 does not downgrade after native associations are removed. Before registering native Sessions, stop and drain older writers and upgrade every participating client to a version that understands schema 2. Older readers cannot read native records. Missing source means manually supplied existing resources; it does not grant ownership for deletion. New relationship writes retain BYO in the internal storage format independently of the public `Membership` record. Unknown sources, fields or schema versions are rejected without resetting or overwriting the record. -Team metadata, phase and declared definitions remain in their existing records. Only relationship links and adoption receipts are kept in one item per relationship domain and owner; read/write cost and contention grow with that owner's links. All writers to adopted records must honor the versioned capability; direct raw mutation or deletion is unsupported. Malformed or unknown relationship formats fail rather than being reset. Session existence validation and relationship commit are separate operations, so external Session deletion can leave an association that the application must explicitly remove. +Team metadata, phase and declared definitions remain in their existing records. Only relationship links and adoption receipts are kept in one item per relationship domain and owner; read/write cost and contention grow with that owner's links. There is no benchmark-backed capacity limit; evaluate the expected membership count and concurrent writes against the chosen backend before high-volume use. All writers to adopted records must honor the versioned capability; direct raw mutation or deletion is unsupported. Malformed or unknown relationship formats fail rather than being reset. Session existence validation and relationship commit are separate operations, so external Session deletion can leave an association that the application must explicitly remove. + +JDBC adapters treat only recognized duplicate-key diagnostics as CAS contention. Other or unknown SQL diagnostics propagate with their cause, including duplicate-key errors from drivers whose diagnostic codes are not recognized. This deliberately replaces the older broad integrity-constraint classification; verify the actual driver when adding a backend. The JDBC, MySQL and PostgreSQL extensions keep their classifiers within their independent modules and supported dialects. Unbinding preserves the Agent definition, Session state, native logs and workspace. It does **not** stop runs, revoke tools, update a constructed static `TeamContext`, or unregister `bindSession` notifications. Task boards, mailboxes and static `teamsMode` keep their existing runtime behavior. This capability does not create models, Workers, invitations, schedules or Service Team/Run resources. diff --git a/docs/v2/zh/docs/harness/architecture.md b/docs/v2/zh/docs/harness/architecture.md index 3be23d5b34..8e697a9235 100644 --- a/docs/v2/zh/docs/harness/architecture.md +++ b/docs/v2/zh/docs/harness/architecture.md @@ -107,7 +107,7 @@ var worker = new MemberSession("researcher", "alice", "research-agent", Role.WOR SessionKey.nativeSession("native-logs", workerAgent.sessionKey(workerContext))); ``` -原生登记读取 Session 日志中已提交的 baseline。接入或绑定之前,应通过普通 Agent 调用或 `AgentSession.submit` 任务初始化 Session。仅打开 Session 句柄或接受排队任务,不表示 baseline 已提交。缺少 baseline 时拒绝登记,日志读取错误直接传播;归属操作不写入占位 `AgentState`,也不修复日志。日志后来不可用时,仍可查询归属和使用 token 解除关联。 +原生登记读取 Session 日志中已提交的 baseline。每次存在性校验都会扫描已提交的日志历史,因此接入和绑定的成本随会话长度增长。接入或绑定之前,应通过普通 Agent 调用或 `AgentSession.submit` 任务初始化 Session。仅打开 Session 句柄或接受排队任务,不表示 baseline 已提交。缺少 baseline 时拒绝登记,日志读取错误直接传播;归属操作不写入占位 `AgentState`,也不修复日志。日志后来不可用时,仍可查询归属和使用 token 解除关联。 [NativeTeamMembershipExample](https://github.com/agentscope-ai/agentscope-java/blob/main/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/harness/teams/NativeTeamMembershipExample.java) 通过离线脚本模型执行两个真实原生会话,再使用编程 API 登记、重开客户端及日志后端,最后解除一个关联并检查其 Session 日志仍然保留。在仓库根目录运行: @@ -122,7 +122,7 @@ mvn -B -pl agentscope-examples/documentation exec:exec \ ### 身份与所有权 -- `TeamAddress(namespace, teamName)` 是已有存储/路由地址。接入时显式提供,拒绝 null/空白 namespace,避免静默迁移历史记录;非空白字符串原样保留。逻辑 `teamId` 和 `displayName` 独立;不同地址的 Team 可以使用相同展示名。同一地址只能由一个 owner 和关系域接入。 +- `TeamAddress(namespace, teamName)` 是已有存储/路由地址。接入时显式提供,拒绝 null/空白 namespace,避免静默迁移历史记录;非空白字符串原样保留。若 `createTeam` 收到 null namespace,Team 实际保存在 `"default"` 下;构造接入地址时应使用该 namespace,也可从返回的 `TeamInfo` 读取。逻辑 `teamId` 和 `displayName` 独立;不同地址的 Team 可以使用相同展示名。同一地址只能由一个 owner 和关系域接入。 - `SessionKey(stateStoreDomain, owner, sessionId)` 保留旧状态槽身份及 JSON 形状。四参数形式增加运行 `agentId`;`nativeSession(logDomain, coreSessionKey)` 保留原生 `(userId, agentId, sessionId)` 身份。运行 `agentId` 与声明定义的 `agentRef` 独立。用户、Agent、Session 或实际存储域不同的原生会话,是不同的 Session。 - 域 ID 标识实际后端及分区路由,与关系域、Team namespace 分开。同一物理分区在所有客户端使用相同稳定 ID,独立分区使用不同 ID;legacy 与 native 域名不能重叠。将同一底层分区配置成两个域 ID 会绕过 Session 唯一性。应用须处理 provider 别名;额外 tenant 属性参与存储路由时,应配置可信 adapter,将这些属性固定到声明的分区。归属操作打开日志时传入 Session 的 user 和 session ID,不推断额外路由上下文。 - Session owner 必须等于 Team owner。定义 owner 独立记录,可以不同;可信应用编程 API 不提供定义访问或邀请授权。旧存储的存在校验接受已持久化状态,包括旧 state keys;原生存在校验要求已提交的日志 baseline。两种校验都不授予定义访问权限。 @@ -136,15 +136,19 @@ mvn -B -pl agentscope-examples/documentation exec:exec \ 随后在原 Team meta 写标记,再在单个 owner 关系记录中原子提交初始关系和回执。这两次写入中间发生故障或 Session 被并发抢占,仍可能留下 `PENDING`:`getTeam` 返回该状态和空的已提交成员列表,`listMemberships` 明确报错。旧 `listMembers` 及 `broadcastMessage` 继续使用原声明目录,包括原 Session 字段;这不代表新归属已经提交。提交后 `listMembers` 才切换到关系投影。用相同完整 `adoptTeam` 请求重试,包含展示名和初始成员;冲突的初始请求不能覆盖标记。已经提交的接入请求重放只返回当前关系,不恢复后来已解除的成员。 -完全相同的绑定保留 token。解除需要预期 token;实际解除返回 `true`,已无关联返回 `false`。错误 Team 或过期 token 报错;真正解除后重绑会生成新 token。CAS 竞争最多重试十次,之后抛 `TeamConflictException`;存储错误直接传播,不降级为无条件写。提交后响应失败时,可查询或用相同请求重试确认结果;取消订阅不回滚已经提交的关系。 +恢复要求原始初始 Session 仍然可用且可以关联。遇到临时存储故障,应先恢复后端可用性,再重试原请求。若 Session 已被其他 Team 占用,应通过有权执行的应用流程处理该关联后再重试。已删除的 Session 必须通过其受支持的存储/恢复路径还原为原来的持久会话;写入占位状态不属于恢复。若无法恢复这些条件,Team 会保持 `PENDING`:目前没有受支持的取消/放弃接入 API。不要通过删除或改写 marker、owner 记录绕过该状态。 -已接入 Team 的旧 `listMembers` 读取同一关系并投影 `sessionId`,未关联的声明成员投影为空。旧 record 结构、声明的 `isLead`、`phase` 和 `deployMode` 不变。旧数据已有非空 Session 字段时,初始接入必须显式提供对应完整身份。`completeTeam` 仍只将原 phase 改为 `Completed`,保留关系及接入标记。成功调用 `sessionMembership(...)` 后,当前 client 启用严格完成 CAS,包括尚未接入的 Team;新实例观察到接入标记时也使用严格 CAS。持续竞争时报冲突,避免覆盖其他写入方。未启用 client 对未接入 Team 保留旧完成 fallback,包括版本存储。 +完全相同的绑定保留 token。解除需要预期 token;实际解除返回 `true`,已无关联返回 `false`。观察到绑定存在时,错误 Team 或过期 token 报错;真正解除后重绑会生成新 token。`false` 表示操作期间观察到无关联,不保证调用返回时仍未绑定:与解除重叠的新绑定可能随后完成,该新关系会被保留。CAS 竞争最多重试十次,之后抛 `TeamConflictException`;存储错误直接传播,不降级为无条件写。提交后响应失败时,可查询或用相同请求重试确认结果;取消订阅不回滚已经提交的关系。 + +已接入 Team 的旧 `listMembers` 读取同一关系并投影 `sessionId`,未关联的声明成员投影为空。旧 record 结构、声明的 `isLead`、`phase` 和 `deployMode` 不变。已有 `TeamTool.listMembers` 会序列化这份名册,因此模型可见的 `sessionId` 来自实际关联的 Session,解绑后为空;输出不包含 binding token、关系记录或完整原生存储身份。旧数据已有非空 Session 字段时,初始接入必须显式提供对应完整身份。`completeTeam` 仍只将原 phase 改为 `Completed`,保留关系及接入标记。成功调用 `sessionMembership(...)` 后,当前 client 启用严格完成 CAS,包括尚未接入的 Team;新实例观察到接入标记时也使用严格 CAS。持续竞争时报冲突,避免覆盖其他写入方。未启用 client 对未接入 Team 保留旧完成 fallback,包括版本存储。 接入 Team 前,先停止该 Team 的旧版本及未启用写入方,等待其在途写操作结束,再升级所有参与写入的实例,并在每个 client 上调用 `sessionMembership(...)` 启用保护。启用 client 不会停止已经进入旧路径的写操作。旧二进制不理解归属投影,其无条件完成 fallback 可能清掉并发接入标记。已接入 Team 不支持新旧版本混写。回滚需使用理解现有存储格式的版本,或停止全部写入方后恢复接入前的备份。 纯 legacy 关系保留 schema 1,可以读取早期包含 `source: "BYO"` 的记录,以及曾省略该字段的草案记录。原生身份使用 schema 2;owner 记录升级到 schema 2 后,即使解除全部原生关联也不降级。登记原生 Session 前,须停止并排空旧写入者,将所有参与客户端升级到理解 schema 2 的版本;旧读取器无法读取原生记录。缺省来源只表示手工提供的既有资源,不授予删除权。新关系写入在内部存储格式中保留 BYO,与公开 `Membership` record 分开。未知来源、字段或格式版本明确拒绝,不重置或覆盖原记录。 -Team 元数据、phase 和声明定义继续使用原记录;每个关系域、每个 owner 的单条记录只保存关联和接入回执,读写成本与竞争随该 owner 的关联数增加。已接入记录的所有写入者须遵守版本能力;不支持直接原始修改或删除。损坏或未知关系格式会报错,不重置为空。Session 存在校验与关系提交是独立操作,外部删除 Session 可能留下关联,应用须显式解除。 +Team 元数据、phase 和声明定义继续使用原记录;每个关系域、每个 owner 的单条记录只保存关联和接入回执,读写成本与竞争随该 owner 的关联数增加。目前没有基于压测的容量上限;大规模使用前,应结合实际后端评估预期关联数和并发写入量。已接入记录的所有写入者须遵守版本能力;不支持直接原始修改或删除。损坏或未知关系格式会报错,不重置为空。Session 存在校验与关系提交是独立操作,外部删除 Session 可能留下关联,应用须显式解除。 + +JDBC adapter 仅将已识别的重复键诊断视为 CAS 竞争。其他或未知 SQL 诊断保留原因传播,包括诊断码尚未识别的驱动重复键错误。这是对旧版宽泛完整性约束分类的有意收紧;新增后端时应验证实际驱动。JDBC、MySQL 和 PostgreSQL 扩展分别在自己的独立模块及支持方言内维护分类。 解除保留 Agent 定义、Session 状态、原生日志和 workspace,不停止运行、撤销工具权限、更新已构造的静态 `TeamContext` 或注销 `bindSession` 通知。任务板、mailbox 和静态 `teamsMode` 保留原运行语义。该能力不创建模型、Worker、邀请、调度或 Service Team/Run 资源。