@@ -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