Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

deployments: retry on reconnect and add a reaper

+555 -26
+1
apps/sower/lib/sower/application.ex
··· 17 17 {Phoenix.PubSub, name: Sower.PubSub}, 18 18 SowerWeb.Presence, 19 19 {Task.Supervisor, name: Sower.TaskSupervisor}, 20 + Sower.Orchestration.StaleDeploymentFinalizer, 20 21 SowerWeb.Endpoint, 21 22 :systemd.ready() 22 23 ]
+184 -25
apps/sower/lib/sower/orchestration.ex
··· 14 14 15 15 require Logger 16 16 17 + @default_stale_after_seconds 2 * 60 * 60 18 + @default_stale_batch_size 100 19 + 17 20 @doc """ 18 21 Returns the list of agents. 19 22 ··· 329 332 end 330 333 331 334 @doc """ 335 + List unresolved deployments for a specific agent, oldest dispatch first. 336 + """ 337 + def list_unresolved_deployments_for_agent(%Agent{} = agent, opts \\ []) do 338 + limit = Keyword.get(opts, :limit) 339 + 340 + query = 341 + from(d in Deployment, 342 + where: d.agent_id == ^agent.id and is_nil(d.result), 343 + order_by: [ 344 + asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), 345 + asc: d.inserted_at 346 + ] 347 + ) 348 + 349 + query = 350 + if is_integer(limit) and limit > 0 do 351 + from(d in query, limit: ^limit) 352 + else 353 + query 354 + end 355 + 356 + query 357 + |> Repo.all() 358 + |> Repo.preload([:subscriptions, seeds: [:tags]]) 359 + end 360 + 361 + @doc """ 362 + Replay unresolved deployments for an agent. 363 + """ 364 + def replay_unresolved_deployments(%Agent{} = agent, opts \\ []) do 365 + broadcast_fun = Keyword.get(opts, :broadcast_fun, &SowerWeb.Endpoint.broadcast/3) 366 + 367 + request_id_fun = 368 + Keyword.get(opts, :request_id_fun, fn -> SowerClient.Sid.generate("request") end) 369 + 370 + now = Keyword.get(opts, :now, DateTime.utc_now()) 371 + 372 + deployments = list_unresolved_deployments_for_agent(agent) 373 + mark_deployments_dispatched(deployments, now) 374 + 375 + Enum.each(deployments, fn deployment -> 376 + payload = deployment_event_payload(deployment, request_id_fun.()) 377 + broadcast_fun.("agent:#{agent.sid}", "deployment", payload) 378 + end) 379 + 380 + if deployments != [] do 381 + Logger.info( 382 + msg: "Replayed unresolved deployments", 383 + agent_sid: agent.sid, 384 + deployment_count: length(deployments), 385 + deployment_sids: Enum.map(deployments, & &1.sid) 386 + ) 387 + end 388 + 389 + {:ok, deployments} 390 + end 391 + 392 + @doc """ 332 393 Returns the list of subscriptions. 333 394 334 395 ## Examples ··· 773 834 parent_deployment_id: deployment.id, 774 835 retried_by_user_id: user_id, 775 836 retry_ordinal: max_retry_ordinal + 1, 776 - retried_at: DateTime.utc_now() 837 + retried_at: DateTime.utc_now(), 838 + last_dispatched_at: DateTime.utc_now() 777 839 } 778 840 779 841 case create_deployment(attrs) do ··· 783 845 784 846 request_id = SowerClient.Sid.generate("request") 785 847 786 - seed_deployments = 787 - Enum.map(retry_deployment.seeds, fn seed -> 788 - subscription_sid = 789 - retry_deployment.subscriptions 790 - |> Enum.find(fn sub -> 791 - sub.seed_name == seed.name and sub.seed_type == seed.seed_type 792 - end) 793 - |> case do 794 - nil -> nil 795 - sub -> sub.sid 796 - end 797 - 798 - %SowerClient.Orchestration.SeedDeployment{ 799 - seed: seed, 800 - subscription_sid: subscription_sid 801 - } 802 - end) 803 - 804 848 SowerWeb.Endpoint.broadcast( 805 849 "agent:#{retry_deployment.agent.sid}", 806 850 "deployment", 807 - %{ 808 - request_id: request_id, 809 - sid: retry_deployment.sid, 810 - seed_deployments: seed_deployments, 811 - skipped: false 812 - } 851 + deployment_event_payload(retry_deployment, request_id) 813 852 ) 814 853 815 854 retry_deployment ··· 1127 1166 case create_deployment(%{ 1128 1167 agent_id: agent_id, 1129 1168 content_hash: content_hash, 1169 + last_dispatched_at: DateTime.utc_now(), 1130 1170 seeds: seeds, 1131 1171 subscriptions: subscriptions 1132 1172 }) do ··· 1194 1234 deploy -> 1195 1235 update_deployment(deploy, %{deployed_at: result.deployed_at, result: result.result}) 1196 1236 end 1237 + end 1238 + 1239 + @doc """ 1240 + Finalize stale unresolved deployments. 1241 + """ 1242 + def finalize_stale_deployments(opts \\ []) do 1243 + now = Keyword.get(opts, :now, DateTime.utc_now()) 1244 + stale_after_seconds = Keyword.get(opts, :stale_after_seconds, stale_after_seconds()) 1245 + batch_size = Keyword.get(opts, :batch_size, stale_batch_size()) 1246 + 1247 + if stale_after_seconds <= 0 or batch_size <= 0 do 1248 + {:ok, 0} 1249 + else 1250 + cutoff = DateTime.add(now, -stale_after_seconds, :second) 1251 + 1252 + stale_deployments = 1253 + from(d in Deployment, 1254 + where: is_nil(d.result), 1255 + where: fragment("COALESCE(?, ?) <= ?", d.last_dispatched_at, d.inserted_at, ^cutoff), 1256 + order_by: [ 1257 + asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), 1258 + asc: d.inserted_at 1259 + ], 1260 + limit: ^batch_size 1261 + ) 1262 + |> Repo.all(skip_org_id: true) 1263 + 1264 + finalized = 1265 + Enum.reduce(stale_deployments, 0, fn deployment, acc -> 1266 + case finalize_stale_deployment(deployment, now) do 1267 + {:ok, _} -> acc + 1 1268 + _ -> acc 1269 + end 1270 + end) 1271 + 1272 + if finalized > 0 do 1273 + Logger.info( 1274 + msg: "Finalized stale deployments", 1275 + stale_after_seconds: stale_after_seconds, 1276 + batch_size: batch_size, 1277 + finalized_count: finalized 1278 + ) 1279 + end 1280 + 1281 + {:ok, finalized} 1282 + end 1283 + end 1284 + 1285 + defp deployment_event_payload(%Deployment{} = deployment, request_id) do 1286 + %SowerClient.Orchestration.Deployment{ 1287 + request_id: request_id, 1288 + sid: deployment.sid, 1289 + seed_deployments: build_seed_deployments(deployment.seeds, deployment.subscriptions), 1290 + skipped: false 1291 + } 1292 + end 1293 + 1294 + defp build_seed_deployments(seeds, subscriptions) do 1295 + Enum.map(seeds, fn seed -> 1296 + subscription_sid = 1297 + subscriptions 1298 + |> Enum.find(fn sub -> 1299 + sub.seed_name == seed.name and sub.seed_type == seed.seed_type 1300 + end) 1301 + |> case do 1302 + nil -> nil 1303 + sub -> sub.sid 1304 + end 1305 + 1306 + %SowerClient.Orchestration.SeedDeployment{ 1307 + seed: seed, 1308 + subscription_sid: subscription_sid 1309 + } 1310 + end) 1311 + end 1312 + 1313 + defp mark_deployments_dispatched([], _dispatched_at), do: :ok 1314 + 1315 + defp mark_deployments_dispatched(deployments, dispatched_at) do 1316 + ids = Enum.map(deployments, & &1.id) 1317 + now = DateTime.utc_now() 1318 + 1319 + from(d in Deployment, 1320 + where: d.id in ^ids and is_nil(d.result) 1321 + ) 1322 + |> Repo.update_all(set: [last_dispatched_at: dispatched_at, updated_at: now]) 1323 + 1324 + :ok 1325 + end 1326 + 1327 + defp finalize_stale_deployment(%Deployment{} = deployment, now) do 1328 + previous_org_id = Repo.get_org_id() 1329 + Repo.put_org_id(deployment.org_id) 1330 + 1331 + result = 1332 + case Repo.get(Deployment, deployment.id) do 1333 + nil -> 1334 + :ignore 1335 + 1336 + %Deployment{result: nil} = unresolved -> 1337 + update_deployment(unresolved, %{deployed_at: now, result: :failure}) 1338 + 1339 + %Deployment{} -> 1340 + :ignore 1341 + end 1342 + 1343 + Repo.put_org_id(previous_org_id) 1344 + 1345 + result 1346 + end 1347 + 1348 + defp stale_after_seconds do 1349 + config = Application.get_env(:sower, __MODULE__, []) 1350 + Keyword.get(config, :stale_after_seconds, @default_stale_after_seconds) 1351 + end 1352 + 1353 + defp stale_batch_size do 1354 + config = Application.get_env(:sower, __MODULE__, []) 1355 + Keyword.get(config, :stale_batch_size, @default_stale_batch_size) 1197 1356 end 1198 1357 1199 1358 alias Sower.Orchestration.{NixProfile, AgentSeedGeneration}
+2
apps/sower/lib/sower/orchestration/deployment.ex
··· 23 23 24 24 field :deployed_at, :utc_datetime 25 25 field :result, Ecto.Enum, values: [:success, :failure, :partial] 26 + field :last_dispatched_at, :utc_datetime_usec 26 27 field :content_hash, :string 27 28 field :retry_ordinal, :integer 28 29 field :retried_at, :utc_datetime_usec ··· 36 37 |> cast(attrs, [ 37 38 :deployed_at, 38 39 :result, 40 + :last_dispatched_at, 39 41 :agent_id, 40 42 :content_hash, 41 43 :parent_deployment_id,
+50
apps/sower/lib/sower/orchestration/stale_deployment_finalizer.ex
··· 1 + defmodule Sower.Orchestration.StaleDeploymentFinalizer do 2 + use GenServer 3 + 4 + require Logger 5 + 6 + alias Sower.Orchestration 7 + 8 + @default_interval_ms :timer.minutes(5) 9 + 10 + def start_link(opts) do 11 + GenServer.start_link(__MODULE__, opts, name: __MODULE__) 12 + end 13 + 14 + @impl GenServer 15 + def init(_opts) do 16 + interval_ms = 17 + Application.get_env(:sower, __MODULE__, []) 18 + |> Keyword.get(:interval_ms, @default_interval_ms) 19 + 20 + if interval_ms > 0 do 21 + schedule(interval_ms) 22 + else 23 + Logger.debug(msg: "Stale deployment finalizer disabled", interval_ms: interval_ms) 24 + end 25 + 26 + {:ok, %{interval_ms: interval_ms}} 27 + end 28 + 29 + @impl GenServer 30 + def handle_info(:run, %{interval_ms: interval_ms} = state) do 31 + {:ok, finalized_count} = Orchestration.finalize_stale_deployments() 32 + 33 + if finalized_count > 0 do 34 + Logger.info( 35 + msg: "Stale deployment finalizer ran", 36 + finalized_count: finalized_count 37 + ) 38 + end 39 + 40 + if interval_ms > 0 do 41 + schedule(interval_ms) 42 + end 43 + 44 + {:noreply, state} 45 + end 46 + 47 + defp schedule(interval_ms) do 48 + Process.send_after(self(), :run, interval_ms) 49 + end 50 + end
+18
apps/sower/lib/sower_web/agent_channel.ex
··· 57 57 58 58 agent when agent.local_sid == local_sid -> 59 59 send(self(), :track_presence) 60 + send(self(), :replay_unresolved_deployments) 60 61 {:ok, %{conn_sid: conn_sid}, assign(socket, :agent, agent)} 61 62 62 63 _ -> ··· 150 151 Presence.track(self(), "agent:presence", socket.assigns.agent.sid, %{ 151 152 online_at: DateTime.utc_now() 152 153 }) 154 + 155 + {:noreply, socket} 156 + end 157 + 158 + def handle_info( 159 + :replay_unresolved_deployments, 160 + %Phoenix.Socket{assigns: %{agent: agent}} = socket 161 + ) do 162 + {:ok, deployments} = Orchestration.replay_unresolved_deployments(agent) 163 + 164 + if deployments != [] do 165 + Logger.debug( 166 + msg: "Replayed unresolved deployments after agent join", 167 + agent_sid: agent.sid, 168 + deployment_count: length(deployments) 169 + ) 170 + end 153 171 154 172 {:noreply, socket} 155 173 end
+1
apps/sower/mix.exs
··· 57 57 {:phoenix_live_reload, "~> 1.2", only: :dev}, 58 58 {:phoenix_live_view, "~> 1.1.0"}, 59 59 {:postgrex, ">= 0.0.0"}, 60 + {:quantum, "~> 3.0"}, 60 61 {:req, ">= 0.5.8"}, 61 62 {:shortuuid, "~> 4.0"}, 62 63 {:sower_client, in_umbrella: true},
+11
apps/sower/priv/repo/migrations/20260302170000_add_last_dispatched_at_to_deployments.exs
··· 1 + defmodule Sower.Repo.Migrations.AddLastDispatchedAtToDeployments do 2 + use Ecto.Migration 3 + 4 + def change do 5 + alter table(:deployments) do 6 + add :last_dispatched_at, :utc_datetime_usec 7 + end 8 + 9 + create index(:deployments, [:result, :last_dispatched_at]) 10 + end 11 + end
+182
apps/sower/test/sower/orchestration_test.exs
··· 988 988 end 989 989 end 990 990 991 + describe "replay_unresolved_deployments/2" do 992 + import Sower.OrchestrationFixtures 993 + 994 + test "replays unresolved deployments and updates dispatch timestamp", %{organization: _org} do 995 + agent = agent_fixture() 996 + seed = seed_fixture(%{name: "replay-host", seed_type: "nixos"}) 997 + 998 + subscription = 999 + subscription_fixture(%{ 1000 + agent_id: agent.id, 1001 + seed_name: seed.name, 1002 + seed_type: seed.seed_type 1003 + }) 1004 + 1005 + unresolved = 1006 + deployment_fixture(%{ 1007 + agent_id: agent.id, 1008 + seeds: [seed], 1009 + subscriptions: [subscription], 1010 + result: nil, 1011 + deployed_at: nil 1012 + }) 1013 + 1014 + _terminal = 1015 + deployment_fixture(%{ 1016 + agent_id: agent.id, 1017 + seeds: [seed], 1018 + subscriptions: [subscription], 1019 + result: :success, 1020 + deployed_at: DateTime.utc_now() 1021 + }) 1022 + 1023 + replayed_at = DateTime.utc_now() |> DateTime.truncate(:second) 1024 + Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{agent.sid}") 1025 + 1026 + assert {:ok, deployments} = 1027 + Orchestration.replay_unresolved_deployments(agent, 1028 + now: replayed_at, 1029 + request_id_fun: fn -> "request_replay_1" end 1030 + ) 1031 + 1032 + assert Enum.map(deployments, & &1.sid) == [unresolved.sid] 1033 + 1034 + assert_receive %Phoenix.Socket.Broadcast{ 1035 + topic: topic, 1036 + event: "deployment", 1037 + payload: payload 1038 + } 1039 + 1040 + assert topic == "agent:#{agent.sid}" 1041 + assert payload.sid == unresolved.sid 1042 + assert payload.skipped == false 1043 + assert payload.request_id == "request_replay_1" 1044 + 1045 + refreshed = Orchestration.get_deployment_sid!(unresolved.sid) 1046 + 1047 + assert DateTime.truncate(refreshed.last_dispatched_at, :second) == 1048 + DateTime.truncate(replayed_at, :second) 1049 + end 1050 + end 1051 + 1052 + describe "finalize_stale_deployments/1" do 1053 + import Sower.OrchestrationFixtures 1054 + 1055 + test "finalizes stale unresolved deployments and keeps fresh unresolved unchanged", %{ 1056 + organization: _org 1057 + } do 1058 + now = DateTime.utc_now() |> DateTime.truncate(:second) 1059 + old_dispatch = DateTime.add(now, -8_000, :second) 1060 + fresh_dispatch = DateTime.add(now, -100, :second) 1061 + agent = agent_fixture() 1062 + 1063 + stale = 1064 + deployment_fixture(%{ 1065 + agent_id: agent.id, 1066 + result: nil, 1067 + deployed_at: nil, 1068 + last_dispatched_at: old_dispatch 1069 + }) 1070 + 1071 + fresh = 1072 + deployment_fixture(%{ 1073 + agent_id: agent.id, 1074 + result: nil, 1075 + deployed_at: nil, 1076 + last_dispatched_at: fresh_dispatch 1077 + }) 1078 + 1079 + assert {:ok, 1} = 1080 + Orchestration.finalize_stale_deployments( 1081 + now: now, 1082 + stale_after_seconds: 3_600, 1083 + batch_size: 10 1084 + ) 1085 + 1086 + stale = Orchestration.get_deployment_sid!(stale.sid) 1087 + assert stale.result == :failure 1088 + assert stale.deployed_at == now 1089 + 1090 + fresh = Orchestration.get_deployment_sid!(fresh.sid) 1091 + assert is_nil(fresh.result) 1092 + assert is_nil(fresh.deployed_at) 1093 + end 1094 + 1095 + test "stale finalization unblocks retry creation for abandoned child retries", %{ 1096 + organization: org 1097 + } do 1098 + user = user_fixture(%{org_id: org.org_id}) 1099 + now = DateTime.utc_now() |> DateTime.truncate(:second) 1100 + old_dispatch = DateTime.add(now, -10_000, :second) 1101 + agent = agent_fixture() 1102 + 1103 + parent = 1104 + deployment_fixture(%{ 1105 + agent_id: agent.id, 1106 + result: :success, 1107 + deployed_at: DateTime.utc_now() 1108 + }) 1109 + 1110 + _child = 1111 + deployment_fixture(%{ 1112 + agent_id: agent.id, 1113 + parent_deployment_id: parent.id, 1114 + retry_ordinal: 1, 1115 + retried_by_user_id: user.id, 1116 + retried_at: DateTime.utc_now(), 1117 + result: nil, 1118 + deployed_at: nil, 1119 + last_dispatched_at: old_dispatch 1120 + }) 1121 + 1122 + assert {:error, :retry_in_progress} = Orchestration.retry_deployment(parent, user.id) 1123 + 1124 + assert {:ok, 1} = 1125 + Orchestration.finalize_stale_deployments( 1126 + now: now, 1127 + stale_after_seconds: 3_600, 1128 + batch_size: 10 1129 + ) 1130 + 1131 + assert {:ok, _retry} = Orchestration.retry_deployment(parent, user.id) 1132 + end 1133 + 1134 + test "late deployment results can still update stale-finalized deployments", %{ 1135 + organization: _org 1136 + } do 1137 + now = DateTime.utc_now() |> DateTime.truncate(:second) 1138 + old_dispatch = DateTime.add(now, -8_000, :second) 1139 + later = DateTime.add(now, 60, :second) 1140 + agent = agent_fixture() 1141 + 1142 + deployment = 1143 + deployment_fixture(%{ 1144 + agent_id: agent.id, 1145 + result: nil, 1146 + deployed_at: nil, 1147 + last_dispatched_at: old_dispatch 1148 + }) 1149 + 1150 + assert {:ok, 1} = 1151 + Orchestration.finalize_stale_deployments( 1152 + now: now, 1153 + stale_after_seconds: 3_600, 1154 + batch_size: 10 1155 + ) 1156 + 1157 + assert {:ok, _updated} = 1158 + Orchestration.record_deployment( 1159 + SowerClient.Orchestration.DeploymentResult.cast!(%{ 1160 + request_id: "request_late_result", 1161 + deployment_sid: deployment.sid, 1162 + result: :success, 1163 + deployed_at: DateTime.to_iso8601(later) 1164 + }) 1165 + ) 1166 + 1167 + refreshed = Orchestration.get_deployment_sid!(deployment.sid) 1168 + assert refreshed.result == :success 1169 + assert refreshed.deployed_at == later 1170 + end 1171 + end 1172 + 991 1173 describe "request_deployment/1 force behavior" do 992 1174 import Sower.OrchestrationFixtures 993 1175
+97
apps/sower/test/sower_web/channels/agent_channel_test.exs
··· 1 + defmodule SowerWeb.AgentChannelTest do 2 + use Sower.DataCase, async: true 3 + 4 + import Sower.AccountsFixtures 5 + import Sower.OrchestrationFixtures 6 + import Sower.SeedFixtures 7 + 8 + alias Phoenix.Socket.Broadcast 9 + alias Sower.Accounts.AccessToken 10 + alias Sower.Orchestration 11 + alias SowerWeb.AgentChannel 12 + 13 + describe "join/3" do 14 + test "schedules replay when agent joins with matching local sid" do 15 + user = user_fixture() 16 + Sower.Repo.put_org_id(user.org_id) 17 + 18 + agent = agent_fixture(%{sid: "agent_join_replay_1", local_sid: "agent_local_1"}) 19 + 20 + socket = %Phoenix.Socket{ 21 + assigns: %{ 22 + conn_sid: "conn_1", 23 + access_token: %AccessToken{org_id: user.org_id} 24 + } 25 + } 26 + 27 + assert {:ok, %{conn_sid: "conn_1"}, joined_socket} = 28 + AgentChannel.join( 29 + "agent:#{agent.sid}", 30 + %{"local_sid" => "agent_local_1"}, 31 + socket 32 + ) 33 + 34 + assert joined_socket.assigns.agent.id == agent.id 35 + assert_received :track_presence 36 + assert_received :replay_unresolved_deployments 37 + end 38 + end 39 + 40 + describe "handle_info/2 replay_unresolved_deployments" do 41 + test "replays unresolved deployments and skips terminal ones" do 42 + user = user_fixture() 43 + Sower.Repo.put_org_id(user.org_id) 44 + 45 + agent = agent_fixture(%{sid: "agent_replay_1"}) 46 + seed = seed_fixture(%{name: "replay-seed-1", seed_type: "nixos"}) 47 + 48 + subscription = 49 + subscription_fixture(%{ 50 + agent_id: agent.id, 51 + seed_name: seed.name, 52 + seed_type: seed.seed_type 53 + }) 54 + 55 + unresolved = 56 + deployment_fixture(%{ 57 + agent_id: agent.id, 58 + seeds: [seed], 59 + subscriptions: [subscription], 60 + result: nil, 61 + deployed_at: nil 62 + }) 63 + 64 + _terminal = 65 + deployment_fixture(%{ 66 + agent_id: agent.id, 67 + seeds: [seed], 68 + subscriptions: [subscription], 69 + result: :success, 70 + deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) 71 + }) 72 + 73 + Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{agent.sid}") 74 + 75 + socket = %Phoenix.Socket{assigns: %{agent: agent}} 76 + 77 + assert {:noreply, ^socket} = 78 + AgentChannel.handle_info(:replay_unresolved_deployments, socket) 79 + 80 + assert_receive %Broadcast{ 81 + topic: topic, 82 + event: "deployment", 83 + payload: payload 84 + } 85 + 86 + assert topic == "agent:#{agent.sid}" 87 + assert payload.sid == unresolved.sid 88 + assert payload.skipped == false 89 + assert is_binary(payload.request_id) 90 + assert is_list(payload.seed_deployments) 91 + 92 + assert Enum.map(Orchestration.list_unresolved_deployments_for_agent(agent), & &1.sid) == [ 93 + unresolved.sid 94 + ] 95 + end 96 + end 97 + end
+6
config/config.exs
··· 24 24 pubsub_server: Sower.PubSub, 25 25 live_view: [signing_salt: "nrwHFIM7"] 26 26 27 + config :sower, Sower.Orchestration, 28 + stale_after_seconds: 2 * 60 * 60, 29 + stale_batch_size: 100 30 + 31 + config :sower, Sower.Orchestration.StaleDeploymentFinalizer, interval_ms: :timer.minutes(5) 32 + 27 33 # Configure esbuild (the version is required) 28 34 config :esbuild, 29 35 version_check: false,
+2
config/test.exs
··· 32 32 config :sower_agent, Client, 33 33 uri: "ws://example.org/socket/websocket", 34 34 reconnect_after_msec: [200, 500, 1_000, 2_000] 35 + 36 + config :sower, Sower.Orchestration.StaleDeploymentFinalizer, interval_ms: 0
+1 -1
justfile
··· 89 89 iex --sname dev1 -S mix phx.server 90 90 91 91 start-agent: 92 - iex --sname agent1 --dot-iex ./.iex-agent.exs -S mix run --no-start 92 + nix shell ".#activator" -c iex --sname agent1 --dot-iex ./.iex-agent.exs -S mix run --no-start 93 93 94 94 start-server: 95 95 iex --sname server1 --dot-iex ./.iex-server.exs -S mix phx.server --no-start