fleetd #209 follow-up: keep roster() off the resolve path
roster() is the supplier for LeadHeartbeatLoop and FleetHealthMonitor (both timer-driven) and for placement/exhaustion checks and the metrics scrape — none of which read agentSessionId. Resolving there meant every tick could open a lazy-resolving adapter's (opencode's) on-disk session database once per member whose id was still unknown, with no bound: a member whose id never appears would pay that cost for the life of the process. roster() goes back to its pre-#209 behavior (no resolve, no I/O). A new rosterResolved() carries the resolve logic, and is used only by the two surfaces that actually report agentSessionId to a caller: fleet_list (FleetMcp.listFleet) and the REST roster (FleetApp.listMembers). fleet_whoami's roster().stream() at FleetMcp.java:768 does not surface the field, so it stays on the plain roster(). get(paneId) (fleet_status) and the release() resolve are caller-driven, not timers, and are unchanged. Retargeted the roster-facing tests from #209 at rosterResolved(), and added plainRosterDoesNotResolveAgentSessionId, which pins the split by asserting the handle's agentSessionId() is not called again by roster().
This commit is contained in:
@@ -951,7 +951,10 @@ public final class FleetMcp {
|
|||||||
.sorted(Map.Entry.comparingByValue())
|
.sorted(Map.Entry.comparingByValue())
|
||||||
.map(e -> leadView(e.getKey(), e.getValue(), live.get(e.getKey()), selfTerm))
|
.map(e -> leadView(e.getKey(), e.getValue(), live.get(e.getKey()), selfTerm))
|
||||||
.toList();
|
.toList();
|
||||||
List<MemberSession> roster = sessions.roster();
|
// fleetd #209: this is the caller-driven fleet_list read that actually reports
|
||||||
|
// agentSessionId (via memberCapacityView -> SessionManager.rosterView), so it uses the
|
||||||
|
// resolving roster; the heartbeat/health/metrics timers stay on the plain sessions.roster().
|
||||||
|
List<MemberSession> roster = sessions.rosterResolved();
|
||||||
List<Map<String, Object>> out = roster.stream()
|
List<Map<String, Object>> out = roster.stream()
|
||||||
.map(s -> memberCapacityView(s, live.get(s.terminalId()), messages, capacity.clock().getAsLong()))
|
.map(s -> memberCapacityView(s, live.get(s.terminalId()), messages, capacity.clock().getAsLong()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -315,7 +315,9 @@ public final class FleetApp {
|
|||||||
.map(Agent.class::cast)
|
.map(Agent.class::cast)
|
||||||
.filter(a -> a.terminalId() != null)
|
.filter(a -> a.terminalId() != null)
|
||||||
.collect(Collectors.toMap(Agent::terminalId, Function.identity(), (_, b) -> b));
|
.collect(Collectors.toMap(Agent::terminalId, Function.identity(), (_, b) -> b));
|
||||||
List<Map<String, Object>> out = sessions.roster().stream()
|
// fleetd #209: this REST roster reports agentSessionId via SessionManager.rosterView, so it
|
||||||
|
// uses the resolving roster read (caller-driven, not a timer) rather than the plain one.
|
||||||
|
List<Map<String, Object>> out = sessions.rosterResolved().stream()
|
||||||
.map(s -> SessionManager.rosterView(s, live.get(s.terminalId())))
|
.map(s -> SessionManager.rosterView(s, live.get(s.terminalId())))
|
||||||
.toList();
|
.toList();
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
|||||||
@@ -567,12 +567,28 @@ public final class SessionManager implements TurnListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fleet-owned roster: all registered sessions (acquired minus released). fleetd #209: each
|
* Fleet-owned roster: all registered sessions (acquired minus released). Deliberately does
|
||||||
* session with a still-unknown {@code agentSessionId} is re-resolved against its retained
|
* <strong>not</strong> resolve {@code agentSessionId} (fleetd #209 follow-up) — this is the
|
||||||
* handle, so {@code fleet_list} reports an id a lazy-resolving adapter (opencode) has since
|
* roster supplier on the heartbeat and health-tick timers ({@code LeadHeartbeatLoop},
|
||||||
* written, rather than the null frozen in at spawn time.
|
* {@code FleetHealthMonitor} in {@code Fleetd}), on the placement/exhaustion paths, and on the
|
||||||
|
* metrics scrape ({@code FleetMetrics}), all called far more often than any caller actually
|
||||||
|
* reads {@code agentSessionId}. Resolving here would mean every tick opens a lazy-resolving
|
||||||
|
* adapter's (opencode's) on-disk session store once per member whose id is still unknown — and
|
||||||
|
* for a member whose id never appears, that cost never stops, for the life of the process. Use
|
||||||
|
* {@link #rosterResolved()} instead wherever the id must be current.
|
||||||
*/
|
*/
|
||||||
public List<MemberSession> roster() {
|
public List<MemberSession> roster() {
|
||||||
|
return List.copyOf(registry.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #roster()}, with each session's still-unknown {@code agentSessionId} re-resolved
|
||||||
|
* against its retained handle (fleetd #209) — so a caller that actually reports the id (
|
||||||
|
* {@code fleet_list}, the REST roster) sees one a lazy-resolving adapter (opencode) has since
|
||||||
|
* written, rather than the null frozen in at spawn time. Reserved for caller-driven reads, not
|
||||||
|
* timers: see {@link #roster()}'s javadoc for why the plain roster must stay non-resolving.
|
||||||
|
*/
|
||||||
|
public List<MemberSession> rosterResolved() {
|
||||||
return registry.values().stream().map(this::resolveAgentSessionId).toList();
|
return registry.values().stream().map(this::resolveAgentSessionId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1070,7 +1070,27 @@ class SessionManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rosterResolvesALateAgentSessionIdFromTheRetainedHandle() {
|
void plainRosterDoesNotResolveAgentSessionId() {
|
||||||
|
// fleetd #209 follow-up: roster() sits on the heartbeat/health-tick timers (and the metrics
|
||||||
|
// scrape), so it must never trigger the resolve lookup — for opencode that lookup opens an
|
||||||
|
// on-disk session database, and a member whose id never appears would pay that cost forever.
|
||||||
|
// rosterResolved() is the one to use when a caller actually reports the id.
|
||||||
|
LazyIdHandle handle = new LazyIdHandle("p0", "t0", 1, "oc-session-0");
|
||||||
|
SessionManager sessions = new SessionManager(new LazyIdLauncher().queue(handle));
|
||||||
|
MemberSession acquired = sessions.acquire("lazy", "/cwd", "/caller", null);
|
||||||
|
assertEquals(1, handle.callCount(), "sanity: only the spawn-time call happened so far");
|
||||||
|
|
||||||
|
List<MemberSession> roster = sessions.roster();
|
||||||
|
|
||||||
|
assertEquals(1, roster.size());
|
||||||
|
assertEquals(acquired.paneId(), roster.getFirst().paneId());
|
||||||
|
assertNull(roster.getFirst().agentSessionId(), "the plain roster must not resolve the id");
|
||||||
|
assertEquals(1, handle.callCount(),
|
||||||
|
"roster() must never call agentSessionId() again — it sits on the heartbeat/health timers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rosterResolvedResolvesALateAgentSessionIdFromTheRetainedHandle() {
|
||||||
LazyIdHandle handle = new LazyIdHandle("p1", "t1", 1, "oc-session-1");
|
LazyIdHandle handle = new LazyIdHandle("p1", "t1", 1, "oc-session-1");
|
||||||
SessionManager sessions = new SessionManager(new LazyIdLauncher().queue(handle));
|
SessionManager sessions = new SessionManager(new LazyIdLauncher().queue(handle));
|
||||||
|
|
||||||
@@ -1078,14 +1098,14 @@ class SessionManagerTest {
|
|||||||
assertNull(acquired.agentSessionId(),
|
assertNull(acquired.agentSessionId(),
|
||||||
"opencode has not written its session row yet at spawn time");
|
"opencode has not written its session row yet at spawn time");
|
||||||
|
|
||||||
List<MemberSession> roster = sessions.roster();
|
List<MemberSession> roster = sessions.rosterResolved();
|
||||||
assertEquals(1, roster.size());
|
assertEquals(1, roster.size());
|
||||||
assertEquals("oc-session-1", roster.getFirst().agentSessionId(),
|
assertEquals("oc-session-1", roster.getFirst().agentSessionId(),
|
||||||
"fleet_list must see the id once the adapter can answer it");
|
"fleet_list must see the id once the adapter can answer it");
|
||||||
|
|
||||||
Map<String, Object> view = SessionManager.rosterView(roster.getFirst(), null);
|
Map<String, Object> view = SessionManager.rosterView(roster.getFirst(), null);
|
||||||
assertEquals("oc-session-1", view.get("agentSessionId"),
|
assertEquals("oc-session-1", view.get("agentSessionId"),
|
||||||
"rosterView renders whatever roster() resolved");
|
"rosterView renders whatever rosterResolved() resolved");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1116,13 +1136,13 @@ class SessionManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aThrowingHandleDoesNotBreakRoster() {
|
void aThrowingHandleDoesNotBreakRosterResolved() {
|
||||||
LazyIdHandle handle = new LazyIdHandle("p4", "t4", 1, "oc-session-4")
|
LazyIdHandle handle = new LazyIdHandle("p4", "t4", 1, "oc-session-4")
|
||||||
.throwing(new RuntimeException("sqlite locked"));
|
.throwing(new RuntimeException("sqlite locked"));
|
||||||
SessionManager sessions = new SessionManager(new LazyIdLauncher().queue(handle));
|
SessionManager sessions = new SessionManager(new LazyIdLauncher().queue(handle));
|
||||||
sessions.acquire("lazy", "/cwd", "/caller", null);
|
sessions.acquire("lazy", "/cwd", "/caller", null);
|
||||||
|
|
||||||
List<MemberSession> roster = assertDoesNotThrow(sessions::roster,
|
List<MemberSession> roster = assertDoesNotThrow(sessions::rosterResolved,
|
||||||
"a handle that throws resolving its id must not break the roster read");
|
"a handle that throws resolving its id must not break the roster read");
|
||||||
|
|
||||||
assertEquals(1, roster.size());
|
assertEquals(1, roster.size());
|
||||||
@@ -1136,10 +1156,10 @@ class SessionManagerTest {
|
|||||||
sessions.acquire("lazy", "/cwd", "/caller", null);
|
sessions.acquire("lazy", "/cwd", "/caller", null);
|
||||||
assertEquals(1, handle.callCount(), "sanity: only the spawn-time call happened so far");
|
assertEquals(1, handle.callCount(), "sanity: only the spawn-time call happened so far");
|
||||||
|
|
||||||
sessions.roster();
|
sessions.rosterResolved();
|
||||||
assertEquals(2, handle.callCount(), "the first roster read resolves the id");
|
assertEquals(2, handle.callCount(), "the first rosterResolved() read resolves the id");
|
||||||
|
|
||||||
sessions.roster();
|
sessions.rosterResolved();
|
||||||
assertEquals(2, handle.callCount(), "once resolved, the id must not be looked up again");
|
assertEquals(2, handle.callCount(), "once resolved, the id must not be looked up again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user