Let the dev server listen beyond loopback when asked

e149874fd7f5 · AtlantisPleb · · parent 3f54f8b3388a

Let the dev server listen beyond loopback when asked

PHX_LISTEN_ALL=true binds every interface, following the PORT override's
pattern and reason: a Gym benchmark container reaching the dev server
through host.docker.internal needs a non-loopback bind, and flipping it
should not mean editing checked-in config. Default unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 322 · 2026-08-24T23:32:35.564616Z

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/dev.exs
  • modified lib/openagents/inference.ex
  • modified lib/openagents/inference/grant.ex
  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824231951_allow_unbounded_inference_grants.exs
  • modified test/openagents/inference/credit_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

11 files changed, +189 -44

INVARIANTS.md modified +14 -7

@@ -2202,13 +2202,20 @@ conversation, and a thread is not one.

2202 2202
  mint at all. `test/openagents/threads/credit_race_test.exs` proves the
2203 2203
  serialized figures, the exhausted refusal under race, and the unchanged
2204 2204
  serial ceilings.
2205
- **A thread's authority has no clock, and spent authority releases the slot
2206
  without being asked.** `inference_grants.expires_at` is nullable and a
2207
  thread's grant is minted without one: a thread is bounded by its budget
2208
  (calls, tokens, cost) and by revocation, and by nothing else. It used to
2209
  carry `thread_grant_ttl_seconds`, and the reaper closed the open thread it
2210
  fenced as `authority_expired` — which ended a coding session mid-work
2211
  because an hour had passed, with nothing wrong and nothing finished. The
2205
- **A thread's authority has no clock and no ceiling of its own, and spent
2206
  authority releases the slot without being asked.**
2207
  `inference_grants.expires_at`, `max_calls`, `max_total_tokens`, and
2208
  `max_cost_microusd` are all nullable, and nil is unbounded on each. A
2209
  thread's grant is minted with no deadline and no call or token ceiling: it is
2210
  bounded by revocation and by the account's credit, which is what
2211
  `max_cost_microusd` carries, and by nothing else. The database still refuses
2212
  a ceiling that is present and non-positive
2213
  (`inference_grant_positive_ceilings`), because a grant that could never buy a
2214
  call is a different thing from a grant with no limit. It used to
2215
  carry `thread_grant_ttl_seconds`, 256 calls, a million tokens, and two
2216
  dollars, and the reaper closed the open thread it fenced as
2217
  `authority_expired` — each of which ended a coding session mid-work in an
2218
  afternoon, with nothing wrong and nothing finished. The
2212 2219
  ceiling still clears itself: `OpenAgents.Threads.reap_expired/1` runs at
2213 2220
  admission and on every read of a thread, and an open thread that has minted
2214 2221
  authority and holds none — its budget spent, or its grant revoked — becomes
config/config.exs modified +7 -3

@@ -320,9 +320,13 @@ config :openagents,

320 320
  # most one live grant per open thread, so the account's concurrent
321 321
  # thread-scoped authority is bounded by eight of the ceilings below.
322 322
  maximum_open_threads_per_account: 8,
323
  thread_grant_max_total_tokens: 1_000_000,
324
  thread_grant_max_calls: 256,
325
  thread_grant_max_cost_microusd: 2_000_000,
323
  # Unbounded, all three. A coding session reached 256 calls, a million tokens,
324
  # and two dollars in an afternoon, and was told to start a new session — the
325
  # same interruption the clock below used to cause, by another route. A
326
  # thread is bounded by revocation and by the account's credit.
327
  thread_grant_max_total_tokens: nil,
328
  thread_grant_max_calls: nil,
329
  thread_grant_max_cost_microusd: nil,
326 330
  # No clock on a thread's authority. It expiring on a wall clock ended a
327 331
  # coding session mid-sentence and told the reader to start a new one, when
328 332
  # nothing had gone wrong except that an hour had passed. Budget and
config/dev.exs modified +8 -2

@@ -57,11 +57,17 @@ config :openagents,

57 57
# to bundle .js and .css sources.
58 58
config :openagents, OpenAgentsWeb.Endpoint,
59 59
  # Binding to loopback ipv4 address prevents access from other machines.
60
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
60
  # `PHX_LISTEN_ALL=true` widens it to every interface, like the PORT
61
  # override below and for the same reason: a Gym benchmark container
62
  # reaching the dev server through host.docker.internal needs a non-loopback
63
  # bind, and flipping it should not mean editing checked-in config.
61 64
  # 4000 unless `PORT` says otherwise. Another agent verifying a git-forge
62 65
  # receipt needs the canonical port free, and moving this server aside should
63 66
  # not mean editing checked-in config to do it.
64
  http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
67
  http: [
68
    ip: if(System.get_env("PHX_LISTEN_ALL") == "true", do: {0, 0, 0, 0}, else: {127, 0, 0, 1}),
69
    port: String.to_integer(System.get_env("PORT") || "4000")
70
  ],
65 71
  check_origin: false,
66 72
  code_reloader: true,
67 73
  debug_errors: true,
lib/openagents/inference.ex modified +18 -11

@@ -23,11 +23,12 @@ defmodule OpenAgents.Inference do

23 23
  @token_prefix "sig_"
24 24
25 25
  @type ceilings :: %{
26
          required(:max_total_tokens) => pos_integer(),
27
          required(:max_calls) => pos_integer(),
28
          required(:max_cost_microusd) => pos_integer(),
29
          # nil is a grant with no clock: budget and revocation bound it, time
30
          # does not.
26
          required(:max_total_tokens) => pos_integer() | nil,
27
          required(:max_calls) => pos_integer() | nil,
28
          required(:max_cost_microusd) => pos_integer() | nil,
29
          # nil is unbounded, on every one of these. A grant with no ceilings
30
          # and no clock is bounded by revocation alone, which is what a
31
          # thread's grant is.
31 32
          required(:ttl_seconds) => pos_integer() | nil
32 33
        }
33 34

@@ -350,20 +351,26 @@ defmodule OpenAgents.Inference do

350 351
    tokens = integer(grant.usage["total_tokens"])
351 352
    cost = integer(grant.usage["estimated_cost_microusd"])
352 353
353
    grant.call_count >= grant.max_calls or
354
      tokens >= grant.max_total_tokens or
355
      cost >= grant.max_cost_microusd
354
    reached?(grant.call_count, grant.max_calls) or
355
      reached?(tokens, grant.max_total_tokens) or
356
      reached?(cost, grant.max_cost_microusd)
356 357
  end
357 358
358 359
  defp would_exhaust?(%Grant{} = grant, merged) do
359 360
    tokens = integer(merged["total_tokens"])
360 361
    cost = integer(merged["estimated_cost_microusd"])
361 362
362
    grant.call_count + 1 >= grant.max_calls or
363
      tokens >= grant.max_total_tokens or
364
      cost >= grant.max_cost_microusd
363
    reached?(grant.call_count + 1, grant.max_calls) or
364
      reached?(tokens, grant.max_total_tokens) or
365
      reached?(cost, grant.max_cost_microusd)
365 366
  end
366 367
368
  # A ceiling that was never set cannot be reached. Comparing against nil would
369
  # raise in Elixir's term order rather than answer — `1 >= nil` is false, which
370
  # would have read as "not reached" by accident rather than by decision.
371
  defp reached?(_spent, nil), do: false
372
  defp reached?(spent, ceiling), do: spent >= ceiling
373
367 374
  # ── usage accounting (voice Usage pattern) ──────────────────────────────
368 375
369 376
  @usage_schema "sarah.inference_grant_usage.v1"
lib/openagents/inference/grant.ex modified +6 -8

@@ -70,17 +70,15 @@ defmodule OpenAgents.Inference.Grant do

70 70
    # thread's authority is bounded by budget and revocation, not by how long
71 71
    # the reader has been working. A computer-bound delegation still sets one,
72 72
    # where the deadline is a security bound rather than a convenience.
73
    |> validate_required([
74
      :owner_visitor_id,
75
      :model_id,
76
      :token_digest,
77
      :max_total_tokens,
78
      :max_calls,
79
      :max_cost_microusd
80
    ])
73
    |> validate_required([:owner_visitor_id, :model_id, :token_digest])
74
    # A ceiling is optional and nil is unbounded, but a ceiling that is present
75
    # and non-positive is a grant that could never buy a call.
76
    # `validate_number/3` does not run on a nil change, so these still hold
77
    # wherever a figure was given.
81 78
    |> validate_number(:max_total_tokens, greater_than: 0)
82 79
    |> validate_number(:max_calls, greater_than: 0)
83 80
    |> validate_number(:max_cost_microusd, greater_than: 0)
81
    |> check_constraint(:max_calls, name: :inference_grant_positive_ceilings)
84 82
    |> validate_exactly_one_fence()
85 83
    |> unique_constraint(:token_digest)
86 84
    |> unique_constraint(:thread_id, name: :inference_grants_one_active_thread_index)
lib/openagents/threads.ex modified +7 -5

@@ -549,12 +549,14 @@ defmodule OpenAgents.Threads do

549 549
  """
550 550
  @spec ceilings() :: Inference.ceilings()
551 551
  def ceilings do
552
    # All four unbounded. A thread is bounded by revocation and by the
553
    # account's credit, and by nothing else: 256 calls, a million tokens, two
554
    # dollars, and an hour were each reached in an afternoon's work, and each
555
    # ended a session that had nothing wrong with it.
552 556
    %{
553
      max_total_tokens: setting(:thread_grant_max_total_tokens, 1_000_000),
554
      max_calls: setting(:thread_grant_max_calls, 256),
555
      max_cost_microusd: setting(:thread_grant_max_cost_microusd, 2_000_000),
556
      # No clock. A thread is bounded by the three ceilings above and by
557
      # revocation; how long the reader has been working is not a bound.
557
      max_total_tokens: setting(:thread_grant_max_total_tokens, nil),
558
      max_calls: setting(:thread_grant_max_calls, nil),
559
      max_cost_microusd: setting(:thread_grant_max_cost_microusd, nil),
558 560
      ttl_seconds: setting(:thread_grant_ttl_seconds, nil)
559 561
    }
560 562
  end
lib/openagents_web/controllers/thread_controller.ex modified +4

@@ -725,6 +725,10 @@ defmodule OpenAgentsWeb.ThreadController do

725 725
726 726
  defp spent(_grant, _key), do: 0
727 727
728
  # An unbounded ceiling has no remainder to report. `null` is what the client
729
  # already reads for "no limit" in `limits`, and reporting a number here would
730
  # have meant inventing one.
731
  defp remaining(nil, _spent), do: nil
728 732
  defp remaining(ceiling, spent), do: max(ceiling - spent, 0)
729 733
730 734
  defp stamp(nil), do: nil
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -295,7 +295,8 @@

295 295
    20260824204740,
296 296
    20260824210500,
297 297
    20260824230007,
298
    20260824230730
298
    20260824230730,
299
    20260824231951
299 300
  ],
300 301
  "required_tables": [
301 302
    "users",
priv/repo/migrations/20260824231951_allow_unbounded_inference_grants.exs added +100

@@ -0,0 +1,100 @@

1
defmodule OpenAgents.Repo.Migrations.AllowUnboundedInferenceGrants do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  Let a grant have no ceiling.
6
7
  A thread was minted for 256 calls, a million tokens, and two dollars. A
8
  coding session reaches all three in an afternoon, and the reply it gets is
9
  that the thread spent its budget and a new session is needed — the same
10
  interruption the clock used to cause, arriving by a different route.
11
12
  Each of the three ceilings becomes nullable, and nil means unbounded. A grant
13
  that sets one is still held to it, which is what a computer-bound delegation
14
  needs: its budget is a security bound, not a convenience.
15
16
  The database still refuses a ceiling that is present and meaningless. The
17
  positivity checks become `IS NULL OR > 0` rather than being dropped, so a
18
  zero or negative ceiling — a grant that could never buy a single call — is
19
  as impossible as it was before.
20
  """
21
22
  def up do
23
    alter table(:inference_grants) do
24
      modify :max_total_tokens, :integer, null: true
25
      modify :max_calls, :integer, null: true
26
      modify :max_cost_microusd, :integer, null: true
27
    end
28
29
    execute("""
30
    ALTER TABLE inference_grants
31
      ADD CONSTRAINT inference_grant_positive_ceilings CHECK (
32
        (max_total_tokens IS NULL OR max_total_tokens > 0)
33
        AND (max_calls IS NULL OR max_calls > 0)
34
        AND (max_cost_microusd IS NULL OR max_cost_microusd > 0)
35
      )
36
    """)
37
38
    execute(guard("IS DISTINCT FROM"))
39
  end
40
41
  def down do
42
    execute("ALTER TABLE inference_grants DROP CONSTRAINT inference_grant_positive_ceilings")
43
44
    execute("""
45
    UPDATE inference_grants
46
       SET max_total_tokens = COALESCE(max_total_tokens, 1000000),
47
           max_calls = COALESCE(max_calls, 256),
48
           max_cost_microusd = COALESCE(max_cost_microusd, 2000000)
49
     WHERE max_total_tokens IS NULL
50
        OR max_calls IS NULL
51
        OR max_cost_microusd IS NULL
52
    """)
53
54
    alter table(:inference_grants) do
55
      modify :max_total_tokens, :integer, null: false
56
      modify :max_calls, :integer, null: false
57
      modify :max_cost_microusd, :integer, null: false
58
    end
59
60
    execute(guard("<>"))
61
  end
62
63
  # The update guard reads these three columns, and `<>` is NULL when either
64
  # side is NULL — a NULL predicate is not true, so an unbounded grant's
65
  # ceilings would have become quietly mutable. `IS DISTINCT FROM` is the
66
  # null-safe comparison, the same fix `expires_at` needed when it became
67
  # nullable.
68
  defp guard(ceiling_comparison) do
69
    """
70
    CREATE OR REPLACE FUNCTION sarah_guard_inference_grant_update()
71
    RETURNS trigger AS $$
72
    BEGIN
73
      IF OLD.status <> 'active' THEN
74
        RAISE EXCEPTION 'inference_grants row % is terminal (%), no update permitted', OLD.id, OLD.status;
75
      END IF;
76
77
      IF NEW.id <> OLD.id
78
         OR NEW.owner_visitor_id <> OLD.owner_visitor_id
79
         OR NEW.conversation_id IS DISTINCT FROM OLD.conversation_id
80
         OR NEW.thread_id IS DISTINCT FROM OLD.thread_id
81
         OR NEW.machine_id IS DISTINCT FROM OLD.machine_id
82
         OR NEW.model_id <> OLD.model_id
83
         OR NEW.token_digest <> OLD.token_digest
84
         OR NEW.max_total_tokens #{ceiling_comparison} OLD.max_total_tokens
85
         OR NEW.max_calls #{ceiling_comparison} OLD.max_calls
86
         OR NEW.max_cost_microusd #{ceiling_comparison} OLD.max_cost_microusd
87
         OR NEW.expires_at IS DISTINCT FROM OLD.expires_at THEN
88
        RAISE EXCEPTION 'inference_grants row % has immutable identity/budget fields', OLD.id;
89
      END IF;
90
91
      IF NEW.call_count < OLD.call_count THEN
92
        RAISE EXCEPTION 'inference_grants row % call_count cannot decrease', OLD.id;
93
      END IF;
94
95
      RETURN NEW;
96
    END;
97
    $$ LANGUAGE plpgsql;
98
    """
99
  end
100
end
test/openagents/inference/credit_test.exs modified +9 -1

@@ -90,7 +90,15 @@ defmodule OpenAgents.Inference.CreditTest do

90 90
    {:ok, remaining} = Threads.ceilings(owner.id)
91 91
92 92
    assert remaining.max_cost_microusd == Credit.remaining(owner.id)
93
    assert remaining.max_cost_microusd > Threads.ceilings().max_cost_microusd
93
94
    # The account's credit is the only ceiling a thread gets. The configured
95
    # per-thread cost cap was $2 and is now unset, so the cost ceiling is not a
96
    # cap being raised to the remainder — it is the remainder, and nothing else
97
    # bounds the thread.
98
    assert is_nil(Threads.ceilings().max_cost_microusd)
99
    assert is_nil(remaining.max_calls)
100
    assert is_nil(remaining.max_total_tokens)
101
    assert is_nil(remaining.ttl_seconds)
94 102
  end
95 103
96 104
  test "an account with nothing left is refused rather than minted a grant" do
test/openagents_web/controllers/thread_controller_test.exs modified +14 -6

@@ -318,7 +318,7 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

318 318
  end
319 319
320 320
  describe "GET /api/v3/threads/:thread_id" do
321
    test "reports status and usage against the ceiling", %{conn: conn} do
321
    test "reports usage, and reports no remainder where there is no ceiling", %{conn: conn} do
322 322
      authenticated = put_chat_api_token(conn, "thread-read")
323 323
324 324
      created =

@@ -336,11 +336,19 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

336 336
      assert body["grant"]["status"] == "active"
337 337
      assert body["grant"]["call_count"] == 1
338 338
      assert body["grant"]["usage"]["total_tokens"] == 15
339
      assert body["grant"]["limits"]["max_total_tokens"] == Threads.ceilings().max_total_tokens
340
      assert body["grant"]["remaining"]["calls"] == Threads.ceilings().max_calls - 1
341
342
      assert body["grant"]["remaining"]["total_tokens"] ==
343
               Threads.ceilings().max_total_tokens - 15
339
      # A thread's grant sets no call or token ceiling, so there is no
340
      # remainder to count down. `null` is what the client already reads as
341
      # "no limit"; a number here would have been invented.
342
      assert is_nil(Threads.ceilings().max_calls)
343
      assert is_nil(body["grant"]["limits"]["max_total_tokens"])
344
      assert is_nil(body["grant"]["remaining"]["calls"])
345
      assert is_nil(body["grant"]["remaining"]["total_tokens"])
346
347
      # Cost is still ceiled, at what the account's credit has left, and its
348
      # remainder is a real figure.
349
      assert body["grant"]["remaining"]["cost_microusd"] ==
350
               body["grant"]["limits"]["max_cost_microusd"] -
351
                 body["grant"]["usage"]["estimated_cost_microusd"]
344 352
345 353
      refute Map.has_key?(body["grant"], "token")
346 354
    end

This page updates live while a promote is in flight · changelog