Skip to content

Commit 3bf5e87

Browse files
cailmdaleyclaude
andcommitted
refactor(poller): extract Shuttle.Poller.SessionReconciliation
Move the orphan-adoption / live-session reconciliation cluster out of poller.ex into Shuttle.Poller.SessionReconciliation: adopt_orphans, reconcile_orphaned_sessions, adopt_session, candidate_session_lookup, and adopt_known_orphan_session. The functions take the Poller.State struct and return updated state, calling back into Poller for the shared helpers (running_key, fiber_address, uid_for_fiber, runtime_key_for_fiber, list_shuttle_sessions, discover_candidates, fetch_fiber_full, fetch_shuttle_agent_name, start_watcher, live_session_for_fiber), which are promoted to `def @doc false` where they were private. Behavior-preserving; adopt-on-boot + per-poll reconcile is identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016RVNkdUCoxqgGVVhUoApVp
1 parent 892dede commit 3bf5e87

2 files changed

Lines changed: 206 additions & 166 deletions

File tree

lib/shuttle/poller.ex

Lines changed: 16 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ defmodule Shuttle.Poller do
3838
}
3939

4040
alias Shuttle.Poller.Snapshot
41+
alias Shuttle.Poller.SessionReconciliation
4142
alias Shuttle.Poller.StandingRoles
4243

4344
@default_poll_interval_ms 30_000
@@ -428,7 +429,7 @@ defmodule Shuttle.Poller do
428429
# `running` from tmux by adopting any live shuttle sessions — a restart
429430
# re-scans tmux and is immediately correct, and running work survives because
430431
# tmux owns the worker process.
431-
state = adopt_orphans(state)
432+
state = SessionReconciliation.adopt_orphans(state)
432433

433434
# Schedule first tick immediately
434435
state = schedule_tick(state, 0)
@@ -744,7 +745,8 @@ defmodule Shuttle.Poller do
744745
end
745746
end
746747

747-
defp runtime_key_for_fiber(fiber) when is_map(fiber) do
748+
@doc false
749+
def runtime_key_for_fiber(fiber) when is_map(fiber) do
748750
fiber_id = fiber_address(fiber)
749751
metadata_uid(fiber) || fiber_id
750752
end
@@ -973,7 +975,8 @@ defmodule Shuttle.Poller do
973975
# realpath roots in lightcone) and the lightcone store claims it. A store
974976
# whose own `.felt/` is a symlink (case 1) owns nothing; the target store
975977
# enumerates it. Ownership is read from felt's path, never reverse-derived.
976-
defp discover_candidates(state) do
978+
@doc false
979+
def discover_candidates(state) do
977980
{all_fibers, host_map, uid_map} =
978981
Enum.reduce(state.felt_stores, {[], %{}, %{}}, fn host, {acc_fibers, acc_map, acc_uids} ->
979982
case list_shuttle_fibers(host, state) do
@@ -1626,7 +1629,8 @@ defmodule Shuttle.Poller do
16261629

16271630
# Returns the shuttle.agent name for a fiber, reading its block through felt.
16281631
# Used by dispatch paths that don't already hold the block map.
1629-
defp fetch_shuttle_agent_name(fiber_id, state) do
1632+
@doc false
1633+
def fetch_shuttle_agent_name(fiber_id, state) do
16301634
case fetch_shuttle_block(fiber_id, state) do
16311635
{:ok, shuttle} -> shuttle_agent_from_block(shuttle)
16321636
{:error, _} -> nil
@@ -1812,7 +1816,7 @@ defmodule Shuttle.Poller do
18121816

18131817
{:error, :already_running} ->
18141818
# Session exists but we don't have a watcher — adopt it
1815-
state = adopt_session(state, fiber_id)
1819+
state = SessionReconciliation.adopt_session(state, fiber_id)
18161820
state = %{state | dispatch_failures: Map.delete(state.dispatch_failures, fiber_id)}
18171821
{state, {:error, :already_running}}
18181822

@@ -2021,7 +2025,7 @@ defmodule Shuttle.Poller do
20212025
state = %{state | orphans: []}
20222026
state = reconcile_fiber_closures(state)
20232027
state = reconcile_missing_running_sessions(state)
2024-
state = reconcile_orphaned_sessions(state)
2028+
state = SessionReconciliation.reconcile_orphaned_sessions(state)
20252029
state = clean_expired_reservations(state)
20262030
state
20272031
end
@@ -2050,24 +2054,6 @@ defmodule Shuttle.Poller do
20502054
end)
20512055
end
20522056

2053-
defp reconcile_orphaned_sessions(%State{} = state) do
2054-
# Find tmux sessions that exist but have no watcher.
2055-
{:ok, sessions} = list_shuttle_sessions(state)
2056-
running_sessions = Enum.map(state.running, fn {_, meta} -> meta.session end) |> MapSet.new()
2057-
2058-
orphan_sessions = Enum.reject(sessions, &MapSet.member?(running_sessions, &1))
2059-
2060-
if orphan_sessions == [] do
2061-
state
2062-
else
2063-
lookup = candidate_session_lookup(state)
2064-
2065-
Enum.reduce(orphan_sessions, state, fn session, state_acc ->
2066-
adopt_known_orphan_session(state_acc, lookup, session)
2067-
end)
2068-
end
2069-
end
2070-
20712057
defp reconcile_missing_running_sessions(%State{running: running} = state)
20722058
when map_size(running) == 0 do
20732059
state
@@ -2160,68 +2146,6 @@ defmodule Shuttle.Poller do
21602146
@doc false
21612147
def role_kind(shuttle), do: Map.get(shuttle, "kind", Map.get(shuttle, "mode", "oneshot"))
21622148

2163-
defp adopt_orphans(%State{} = state) do
2164-
{:ok, sessions} = list_shuttle_sessions(state)
2165-
lookup = candidate_session_lookup(state)
2166-
2167-
Enum.reduce(sessions, state, fn session, state_acc ->
2168-
adopt_known_orphan_session(state_acc, lookup, session)
2169-
end)
2170-
end
2171-
2172-
# `session` is the *live* tmux session name to adopt. Callers that discovered
2173-
# a live orphan pass its exact name (which may be the legacy leaf-only form on
2174-
# a worker launched before the uid-keyed cutover); the default picks whichever
2175-
# of the fiber's name forms is actually live (preferring the uid-keyed name),
2176-
# for callers that only have the fiber identity.
2177-
defp adopt_session(state, fiber_id, session \\ nil) do
2178-
session =
2179-
session || live_session_for_fiber(state, fiber_id) ||
2180-
Dispatcher.session_name(fiber_id, uid_for_fiber(state, fiber_id))
2181-
2182-
case fetch_fiber_full(fiber_id, state) do
2183-
{:ok, fiber} ->
2184-
if Map.get(fiber, "status") != "closed" do
2185-
agent_name = fetch_shuttle_agent_name(fiber_id, state)
2186-
{:ok, agent} = Shuttle.Agents.resolve_by_name(agent_name)
2187-
2188-
now = DateTime.utc_now()
2189-
2190-
running_meta = %{
2191-
fiber_id: fiber_id,
2192-
session: session,
2193-
agent_id: agent.id,
2194-
uid: Map.get(fiber, "uid"),
2195-
started_at: now,
2196-
last_activity_at: now
2197-
}
2198-
2199-
case start_watcher(state, fiber_id, running_meta) do
2200-
{:ok, running_meta} ->
2201-
runtime_key = runtime_key_for_fiber(fiber)
2202-
running = Map.put(state.running, runtime_key, running_meta)
2203-
2204-
Logger.info("Adopted orphan session: #{session}")
2205-
%{state | running: running, claimed: MapSet.put(state.claimed, fiber_id)}
2206-
2207-
{:error, reason} ->
2208-
Logger.warning("Failed to adopt session #{session}: #{inspect(reason)}")
2209-
state
2210-
end
2211-
else
2212-
# Fiber is closed but tmux session still exists — kill it
2213-
Logger.info("Killing stale session for closed fiber: #{session}")
2214-
_ = state.runner.cmd("tmux", ["kill-session", "-t", session], stderr_to_stdout: true)
2215-
state
2216-
end
2217-
2218-
{:error, _} ->
2219-
# Fiber not found — skip, don't kill (could be from another host or test)
2220-
Logger.debug("Skipping orphan session for unknown fiber: #{session}")
2221-
state
2222-
end
2223-
end
2224-
22252149
# ── Worker Exit Handling ──
22262150

22272151
defp handle_worker_exit(%State{} = state, fiber_id, reason, _session_alive?) do
@@ -2341,7 +2265,8 @@ defmodule Shuttle.Poller do
23412265

23422266
# The fiber's *live* tmux session name (either form), preferring the uid-keyed
23432267
# canonical name when both happen to exist. Returns nil when neither is live.
2344-
defp live_session_for_fiber(%State{} = state, fiber_id) do
2268+
@doc false
2269+
def live_session_for_fiber(%State{} = state, fiber_id) do
23452270
fiber_id
23462271
|> Dispatcher.session_names(uid_for_fiber(state, fiber_id))
23472272
|> Enum.find(&already_running_session?(state, &1))
@@ -2368,7 +2293,8 @@ defmodule Shuttle.Poller do
23682293
}
23692294
end
23702295

2371-
defp start_watcher(%State{} = state, fiber_id, metadata) do
2296+
@doc false
2297+
def start_watcher(%State{} = state, fiber_id, metadata) do
23722298
watcher_opts = [
23732299
fiber_id: fiber_id,
23742300
session: Map.fetch!(metadata, :session),
@@ -2450,7 +2376,8 @@ defmodule Shuttle.Poller do
24502376

24512377
# Fetch a fiber's full JSON representation via the felt CLI. Routes to the
24522378
# fiber's owning host via host_for_fiber/2 (cache → felt resolution).
2453-
defp fetch_fiber_full(fiber_id, state) do
2379+
@doc false
2380+
def fetch_fiber_full(fiber_id, state) do
24542381
host =
24552382
case host_for_fiber(fiber_id, state) do
24562383
{:ok, h} -> h
@@ -2560,83 +2487,6 @@ defmodule Shuttle.Poller do
25602487
end
25612488
end
25622489

2563-
# Maps every live tmux session name a candidate could carry — both the
2564-
# uid-keyed canonical name and the legacy leaf-only name — back to its fiber,
2565-
# so orphan adoption recognizes a worker launched under either scheme. The
2566-
# uid-keyed entries are inherently collision-free; the legacy leaf-only
2567-
# entries keep the existing ambiguity guard (two fibers sharing a leaf resolve
2568-
# to `:ambiguous` and are skipped rather than mis-adopted).
2569-
defp candidate_session_lookup(%State{} = state) do
2570-
{:ok, candidates, _host_map, _uid_map} = discover_candidates(state)
2571-
2572-
candidates
2573-
|> Enum.reduce(%{}, fn fiber, acc ->
2574-
case {Map.get(fiber, "id"), Map.get(fiber, "status")} do
2575-
{fiber_id, status} when is_binary(fiber_id) and fiber_id != "" ->
2576-
bucket = if(status == "closed", do: :closed, else: :open)
2577-
2578-
# Record fiber_id in `bucket` of the session's grouped sets. `Map.update/4`
2579-
# inserts the default VERBATIM when the key is absent — the function is NOT
2580-
# applied to it — so the default must already carry fiber_id. Without this,
2581-
# a session name seen exactly once (every uid-keyed name is unique to one
2582-
# fiber) keeps empty sets, resolves to nil below, and the live worker is
2583-
# never adopted — the daemon-restart-drops-all-adoptions bug.
2584-
add_to_bucket = fn grouped -> Map.update!(grouped, bucket, &MapSet.put(&1, fiber_id)) end
2585-
singleton = add_to_bucket.(%{open: MapSet.new(), closed: MapSet.new()})
2586-
2587-
fiber_id
2588-
|> Dispatcher.session_names(Map.get(fiber, "uid"))
2589-
|> Enum.reduce(acc, fn session, acc2 ->
2590-
Map.update(acc2, session, singleton, add_to_bucket)
2591-
end)
2592-
2593-
_ ->
2594-
acc
2595-
end
2596-
end)
2597-
|> Enum.into(%{}, fn {session, grouped} ->
2598-
open_ids = Map.get(grouped, :open, MapSet.new()) |> MapSet.to_list()
2599-
closed_ids = Map.get(grouped, :closed, MapSet.new()) |> MapSet.to_list()
2600-
2601-
value =
2602-
cond do
2603-
length(open_ids) == 1 -> {:adopt, hd(open_ids)}
2604-
length(open_ids) > 1 -> :ambiguous
2605-
length(closed_ids) == 1 -> {:kill_closed, hd(closed_ids)}
2606-
length(closed_ids) > 1 -> :ambiguous
2607-
true -> nil
2608-
end
2609-
2610-
{session, value}
2611-
end)
2612-
|> Enum.reject(fn {_session, value} -> is_nil(value) end)
2613-
|> Map.new()
2614-
end
2615-
2616-
defp adopt_known_orphan_session(%State{} = state, lookup, session) do
2617-
case Map.get(lookup, session) do
2618-
{:adopt, fiber_id} ->
2619-
if running_key(state, fiber_id) != nil do
2620-
state
2621-
else
2622-
adopt_session(state, fiber_id, session)
2623-
end
2624-
2625-
{:kill_closed, fiber_id} ->
2626-
Logger.info("Killing stale session for closed fiber: #{fiber_id} session=#{session}")
2627-
_ = state.runner.cmd("tmux", ["kill-session", "-t", session], stderr_to_stdout: true)
2628-
state
2629-
2630-
:ambiguous ->
2631-
Logger.warning("Skipping orphan session with ambiguous leaf-only name: #{session}")
2632-
state
2633-
2634-
nil ->
2635-
Logger.debug("Skipping orphan session with no matching fiber: #{session}")
2636-
state
2637-
end
2638-
end
2639-
26402490
@doc false
26412491
def list_shuttle_sessions(state) do
26422492
case state.runner.cmd("tmux", ["ls", "-F", "\#{session_name}"], stderr_to_stdout: true) do

0 commit comments

Comments
 (0)