fleetd #209 follow-up: keep roster() off the resolve path
CI / build (pull_request) Successful in 1m20s
CI / contract (pull_request) Successful in 1m21s

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:
Dai Ha
2026-08-31 22:15:16 +07:00
parent f9fb387427
commit 1fdaa74eb3
4 changed files with 55 additions and 14 deletions
@@ -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");
} }
} }