Take the clock off a thread's authority

5d6f30c758dc · AtlantisPleb · · parent c383c8dbf24e

Take the clock off a thread's authority

"This thread's authority expired. Start a new session to open another."
arrived mid-work, with nothing wrong and nothing finished. An hour had passed,
`reap_expired/1` retired the grant, and the open thread it fenced was closed as
`authority_expired`. A coding session is not an hour long, and there is no
reason for one to be.

`inference_grants.expires_at` is nullable now, and a thread's grant is minted
without one. A thread is bounded by its budget — calls, tokens, cost — and by
revocation. Time is not one of its bounds.

The reaper keeps the half that was never about a clock. An open thread that has
minted authority and holds none is finished whether or not anyone says so: the
only route that mints for a caller mints once, at open, so nothing is coming to
renew it, and leaving it open would hold a slot against the account's ceiling
forever. It is closed as `authority_spent` — the budget ran out, or the grant
was revoked — which is what actually happened. That distinction is the reason
this is not simply a deletion: removing the whole reaper leaked a slot per
exhausted thread, which `credit_race_test.exs` caught.

A grant that does still carry a deadline is unaffected, and is still retired
past it. Those are the ones where the deadline is a security bound rather than
a convenience.

The update guard had to change with the column. `NEW.expires_at <>
OLD.expires_at` is NULL when either side is NULL, and a NULL predicate is not
true, so the column would have become quietly mutable for exactly the rows that
now use it. It uses `IS DISTINCT FROM`, like the other nullable columns in that
guard.

INVARIANTS.md carries the change: the bullet that said expiry revokes without
being asked now says a thread's authority has no clock, and what does still
release the slot.

Two tests asserted the old behaviour by name and are rewritten to assert the
new one, including that the report no longer says "expired". Verified against a
running server: a thread grant is minted with `expires_at` NULL and the session
answers.

4321 of 4323 pass. The two failures are `ExitRehearsalRunbookTest` and
`KeyRotationTest`, both already red on this tip before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <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 319 · 2026-08-24T23:14:33.994902Z

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified lib/openagents/inference.ex
  • modified lib/openagents/inference/grant.ex
  • modified lib/openagents/threads.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824230007_allow_unexpiring_inference_grants.exs
  • modified test/openagents/threads_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

9 files changed, +233 -83

INVARIANTS.md modified +15 -6

@@ -2202,12 +2202,21 @@ 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
- **Expiry revokes without being asked.** `OpenAgents.Threads.reap_expired/1`
2206
  runs at admission and on every read of a thread: an active grant past
2207
  `expires_at` becomes `expired`, and an open thread that has minted authority
2208
  and holds none becomes `failed` with `authority_expired`. An abandoned thread
2209
  therefore cannot hold an account's admission slot, and a lapsed token is not
2210
  merely refused on presentation — it stops being live in the ledger.
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
2212
  ceiling still clears itself: `OpenAgents.Threads.reap_expired/1` runs at
2213
  admission and on every read of a thread, and an open thread that has minted
2214
  authority and holds none — its budget spent, or its grant revoked — becomes
2215
  `failed` with `authority_spent`, so it cannot hold an account's admission
2216
  slot forever. A grant that *does* carry a deadline, where the deadline is a
2217
  security bound rather than a convenience, is still retired past it, and a
2218
  lapsed token stops being live in the ledger rather than merely being refused
2219
  on presentation.
2211 2220
- **Authority reaches only the account that opened the thread.**
2212 2221
  `OpenAgents.Threads.get_for_user/2` joins through the owner visitor, so
2213 2222
  another account's thread id resolves to `nil` and the route refuses it with
config/config.exs modified +5 -1

@@ -323,7 +323,11 @@ config :openagents,

323 323
  thread_grant_max_total_tokens: 1_000_000,
324 324
  thread_grant_max_calls: 256,
325 325
  thread_grant_max_cost_microusd: 2_000_000,
326
  thread_grant_ttl_seconds: 3_600,
326
  # No clock on a thread's authority. It expiring on a wall clock ended a
327
  # coding session mid-sentence and told the reader to start a new one, when
328
  # nothing had gone wrong except that an hour had passed. Budget and
329
  # revocation bound a thread; time does not.
330
  thread_grant_ttl_seconds: nil,
327 331
  # The inference credit an account draws its threads against. Signing in is
328 332
  # what raises it: a visitor holding only a browser key gets the same figure a
329 333
  # single thread used to get, and an account with a user behind it gets $100 to
lib/openagents/inference.ex modified +12 -4

@@ -26,7 +26,9 @@ defmodule OpenAgents.Inference do

26 26
          required(:max_total_tokens) => pos_integer(),
27 27
          required(:max_calls) => pos_integer(),
28 28
          required(:max_cost_microusd) => pos_integer(),
29
          required(:ttl_seconds) => pos_integer()
29
          # nil is a grant with no clock: budget and revocation bound it, time
30
          # does not.
31
          required(:ttl_seconds) => pos_integer() | nil
30 32
        }
31 33
32 34
  @type mint_input :: %{

@@ -84,7 +86,7 @@ defmodule OpenAgents.Inference do

84 86
      max_total_tokens: ceilings.max_total_tokens,
85 87
      max_calls: ceilings.max_calls,
86 88
      max_cost_microusd: ceilings.max_cost_microusd,
87
      expires_at: DateTime.add(now(), ceilings.ttl_seconds, :second)
89
      expires_at: deadline(ceilings)
88 90
    }
89 91
90 92
    changeset = Grant.mint_changeset(attrs)

@@ -173,7 +175,9 @@ defmodule OpenAgents.Inference do

173 175
174 176
      %Grant{status: "active"} = grant ->
175 177
        cond do
176
          DateTime.compare(now(), grant.expires_at) != :lt ->
178
          # A grant with no clock cannot elapse. `DateTime.compare/2` would
179
          # raise on nil rather than answer, so the absence is read first.
180
          not is_nil(grant.expires_at) and DateTime.compare(now(), grant.expires_at) != :lt ->
177 181
            _ = expire(grant)
178 182
            {:error, :grant_expired}
179 183

@@ -304,7 +308,7 @@ defmodule OpenAgents.Inference do

304 308
305 309
    Grant
306 310
    |> where([g], g.owner_visitor_id == ^owner_visitor_id)
307
    |> where([g], g.status == "active" and g.expires_at <= ^stamp)
311
    |> where([g], g.status == "active" and not is_nil(g.expires_at) and g.expires_at <= ^stamp)
308 312
    |> Repo.update_all(set: [status: "expired", exhausted_at: stamp, updated_at: stamp])
309 313
  end
310 314

@@ -463,6 +467,10 @@ defmodule OpenAgents.Inference do

463 467
464 468
  defp grant_ttl_seconds, do: Application.get_env(:openagents, :inference_grant_ttl_seconds, 900)
465 469
470
  # nil ttl means no deadline at all, rather than one computed from nil.
471
  defp deadline(%{ttl_seconds: nil}), do: nil
472
  defp deadline(%{ttl_seconds: seconds}), do: DateTime.add(now(), seconds, :second)
473
466 474
  defp input_price_microusd,
467 475
    do: Application.get_env(:openagents, :inference_input_price_microusd_per_ktoken, 1_250)
468 476
lib/openagents/inference/grant.ex modified +5 -2

@@ -66,14 +66,17 @@ defmodule OpenAgents.Inference.Grant do

66 66
    ])
67 67
    # machine_id is nil for Sarah-internal grants (a coding job metering its
68 68
    # own runtime, #122); computer-bound probe delegations always set it.
69
    # `expires_at` is cast but not required: nil is a grant with no clock. A
70
    # thread's authority is bounded by budget and revocation, not by how long
71
    # the reader has been working. A computer-bound delegation still sets one,
72
    # where the deadline is a security bound rather than a convenience.
69 73
    |> validate_required([
70 74
      :owner_visitor_id,
71 75
      :model_id,
72 76
      :token_digest,
73 77
      :max_total_tokens,
74 78
      :max_calls,
75
      :max_cost_microusd,
76
      :expires_at
79
      :max_cost_microusd
77 80
    ])
78 81
    |> validate_number(:max_total_tokens, greater_than: 0)
79 82
    |> validate_number(:max_calls, greater_than: 0)
lib/openagents/threads.ex modified +35 -24

@@ -40,9 +40,10 @@ defmodule OpenAgents.Threads do

40 40
     that inserts, so two simultaneous opens at the boundary admit one thread,
41 41
     not two — the cap is what makes the account's joint credit exposure a
42 42
     bounded figure, so it has to hold under concurrency (issue #195).
43
  5. **The ceiling is self-clearing.** `reap_expired/1` runs at admission and
44
     on every read: an active grant whose clock has run out becomes `expired`,
45
     and the open thread it fenced becomes `failed` with `authority_expired`.
43
  5. **The ceiling is self-clearing, but not on a clock.** `reap_expired/1`
44
     runs at admission and on every read: a thread that has minted authority
45
     and holds none becomes `failed` with `authority_spent`. A thread's grant
46
     carries no deadline, so waiting alone never reaches it.
46 47
     Expiry therefore releases both the thread's active-grant slot and the
47 48
     account's admission slot without anyone asking, so an abandoned thread
48 49
     cannot lock an account out of its own ceiling.

@@ -552,7 +553,9 @@ defmodule OpenAgents.Threads do

552 553
      max_total_tokens: setting(:thread_grant_max_total_tokens, 1_000_000),
553 554
      max_calls: setting(:thread_grant_max_calls, 256),
554 555
      max_cost_microusd: setting(:thread_grant_max_cost_microusd, 2_000_000),
555
      ttl_seconds: setting(:thread_grant_ttl_seconds, 3_600)
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.
558
      ttl_seconds: setting(:thread_grant_ttl_seconds, nil)
556 559
    }
557 560
  end
558 561

@@ -602,13 +605,21 @@ defmodule OpenAgents.Threads do

602 605
  end
603 606
604 607
  @doc """
605
  Retire the account's elapsed authority, and the threads it fenced.
608
  Retire authority that has ended, and the threads left holding none.
606 609
607
  Expiry is a fact about a clock, not a request: a grant past `expires_at` is
608
  no longer authority whether or not anyone presents it. This transitions those
609
  grants to `expired`, and closes every open thread that has minted authority
610
  and no longer holds any, with `authority_expired`. Returns
611
  `{expired_grants, closed_threads}`.
610
  Two halves, and only one of them used to be about a clock.
611
612
  A grant that carries a deadline and is past it is retired. A thread's grant
613
  carries no deadline — time is not one of a thread's bounds — so this half
614
  reaches only the grants that still set one.
615
616
  A thread that has minted authority and holds none is finished whether or not
617
  anyone says so: the only route that mints for a caller mints once, at open,
618
  so nothing is coming to renew it, and leaving it open holds a slot against
619
  the account's ceiling forever. It is closed as `authority_spent`, which is
620
  what has actually happened — the budget ran out, or the grant was revoked.
621
622
  Returns `{retired_grants, closed_threads}`.
612 623
  """
613 624
  @spec reap_expired(User.t() | Visitor.t()) :: {non_neg_integer(), non_neg_integer()}
614 625
  def reap_expired(%User{} = user),

@@ -621,7 +632,7 @@ defmodule OpenAgents.Threads do

621 632
      visitor_id
622 633
      |> abandoned_thread_ids()
623 634
      |> Enum.count(fn thread_id ->
624
        match?({:ok, _closed}, terminate(%Thread{id: thread_id}, expired_attributes()))
635
        match?({:ok, _closed}, terminate(%Thread{id: thread_id}, spent_attributes()))
625 636
      end)
626 637
627 638
    {expired, closed}

@@ -671,19 +682,7 @@ defmodule OpenAgents.Threads do

671 682
    end)
672 683
  end
673 684
674
  defp expired_attributes do
675
    report = "The thread's model authority expired before it reported."
676
677
    %{
678
      status: "failed",
679
      report: report,
680
      report_digest: digest(report),
681
      error_code: "authority_expired",
682
      completed_at: DateTime.utc_now()
683
    }
684
  end
685
686
  # An open thread that has minted authority and holds none is abandoned: the
685
  # An open thread that has minted authority and holds none is finished: the
687 686
  # only route that mints for a caller mints once, at open, so nothing is
688 687
  # coming to renew it.
689 688
  defp abandoned_thread_ids(visitor_id) do

@@ -703,6 +702,18 @@ defmodule OpenAgents.Threads do

703 702
    )
704 703
  end
705 704
705
  defp spent_attributes do
706
    report = "The thread's model authority was spent before it reported."
707
708
    %{
709
      status: "failed",
710
      report: report,
711
      report_digest: digest(report),
712
      error_code: "authority_spent",
713
      completed_at: DateTime.utc_now()
714
    }
715
  end
716
706 717
  defp setting(key, default), do: Application.get_env(:openagents, key, default)
707 718
708 719
  defp locked(thread_id) do
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -293,7 +293,8 @@

293 293
    20260824184030,
294 294
    20260824203139,
295 295
    20260824204740,
296
    20260824210500
296
    20260824210500,
297
    20260824230007
297 298
  ],
298 299
  "required_tables": [
299 300
    "users",
priv/repo/migrations/20260824230007_allow_unexpiring_inference_grants.exs added +74

@@ -0,0 +1,74 @@

1
defmodule OpenAgents.Repo.Migrations.AllowUnexpiringInferenceGrants do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  Let a grant have no clock.
6
7
  A thread's authority expiring on a wall clock ended a coding session
8
  mid-sentence and told the reader to start a new one. The work was not
9
  finished, nothing had gone wrong, and the only thing that had happened was
10
  that an hour had passed. Budget still bounds a grant — calls, tokens, and
11
  cost — and revocation still ends one immediately. Time no longer does.
12
13
  `expires_at` becomes nullable and nil means "no clock". Grants that still
14
  carry one — a computer-bound delegation, whose deadline is a security bound
15
  rather than a convenience — are unaffected.
16
17
  The update guard has to change with it. `NEW.expires_at <> OLD.expires_at`
18
  is NULL when either side is NULL, and a NULL predicate is not true, so the
19
  column would have become quietly mutable for exactly the rows that now use
20
  it. `IS DISTINCT FROM` is the null-safe comparison the other nullable
21
  columns in this guard already use.
22
  """
23
24
  def up do
25
    alter table(:inference_grants) do
26
      modify :expires_at, :utc_datetime_usec, null: true
27
    end
28
29
    execute(guard("IS DISTINCT FROM"))
30
  end
31
32
  def down do
33
    execute("UPDATE inference_grants SET expires_at = now() WHERE expires_at IS NULL")
34
35
    alter table(:inference_grants) do
36
      modify :expires_at, :utc_datetime_usec, null: false
37
    end
38
39
    execute(guard("<>"))
40
  end
41
42
  defp guard(expires_at_comparison) do
43
    """
44
    CREATE OR REPLACE FUNCTION sarah_guard_inference_grant_update()
45
    RETURNS trigger AS $$
46
    BEGIN
47
      IF OLD.status <> 'active' THEN
48
        RAISE EXCEPTION 'inference_grants row % is terminal (%), no update permitted', OLD.id, OLD.status;
49
      END IF;
50
51
      IF NEW.id <> OLD.id
52
         OR NEW.owner_visitor_id <> OLD.owner_visitor_id
53
         OR NEW.conversation_id IS DISTINCT FROM OLD.conversation_id
54
         OR NEW.thread_id IS DISTINCT FROM OLD.thread_id
55
         OR NEW.machine_id IS DISTINCT FROM OLD.machine_id
56
         OR NEW.model_id <> OLD.model_id
57
         OR NEW.token_digest <> OLD.token_digest
58
         OR NEW.max_total_tokens <> OLD.max_total_tokens
59
         OR NEW.max_calls <> OLD.max_calls
60
         OR NEW.max_cost_microusd <> OLD.max_cost_microusd
61
         OR NEW.expires_at #{expires_at_comparison} OLD.expires_at THEN
62
        RAISE EXCEPTION 'inference_grants row % has immutable identity/budget fields', OLD.id;
63
      END IF;
64
65
      IF NEW.call_count < OLD.call_count THEN
66
        RAISE EXCEPTION 'inference_grants row % call_count cannot decrease', OLD.id;
67
      END IF;
68
69
      RETURN NEW;
70
    END;
71
    $$ LANGUAGE plpgsql;
72
    """
73
  end
74
end
test/openagents/threads_test.exs modified +42 -22

@@ -230,41 +230,61 @@ defmodule OpenAgents.ThreadsTest do

230 230
  end
231 231
232 232
  describe "reap_expired/1" do
233
    test "elapsed authority is expired and the thread it fenced is closed" do
234
      user = owner("reaped")
235
      {:ok, live} = Threads.open(user, "Still working")
236
      {:ok, live, _live_grant, _live_token} = Threads.mint_grant(live)
233
    test "a thread's authority has no clock, so waiting does not end it" do
234
      # The behaviour this replaces: an hour passed, the grant expired, the
235
      # open thread it fenced was closed as `authority_expired`, and a coding
236
      # session that was mid-sentence was told to start a new one.
237
      user = owner("no-clock")
238
      {:ok, thread} = Threads.open(user, "Still working")
239
      {:ok, thread, grant, token} = Threads.mint_grant(thread)
237 240
238
      elapsed_ttl()
239
      {:ok, lapsed} = Threads.open(user, "Abandoned")
240
      {:ok, lapsed, lapsed_grant, lapsed_token} = Threads.mint_grant(lapsed)
241
      assert is_nil(Repo.get!(Grant, grant.id).expires_at)
241 242
242
      assert {1, 1} = Threads.reap_expired(user)
243
      # However long the reaper is run, and whenever.
244
      assert {0, 0} = Threads.reap_expired(user)
245
      assert {0, 0} = Threads.reap_expired(user)
243 246
244
      assert Repo.get!(Grant, lapsed_grant.id).status == "expired"
245
      assert {:error, :grant_expired} = Inference.resolve(lapsed_token)
247
      assert Repo.get!(Grant, grant.id).status == "active"
248
      assert Repo.get!(Thread, thread.id).status == "open"
249
      assert {:ok, _resolved} = Inference.resolve(token)
250
    end
246 251
247
      reaped = Repo.get!(Thread, lapsed.id)
248
      assert reaped.status == "failed"
249
      assert reaped.error_code == "authority_expired"
250
      assert reaped.report_digest =~ ~r/\Asha256:[0-9a-f]{64}\z/
252
    test "a grant that does carry a deadline is still retired when it passes" do
253
      # Not every grant is a thread's. A computer-bound delegation keeps its
254
      # clock, where the deadline is a security bound rather than a
255
      # convenience, and this is the reader that enforces it.
256
      user = owner("reaped")
257
      elapsed_ttl()
258
      {:ok, thread} = Threads.open(user, "Deadline")
259
      {:ok, _thread, grant, token} = Threads.mint_grant(thread)
251 260
252
      # A thread whose clock has not run out is untouched.
253
      assert Repo.get!(Thread, live.id).status == "open"
261
      assert {1, 1} = Threads.reap_expired(user)
262
      assert Repo.get!(Grant, grant.id).status == "expired"
263
      assert {:error, :grant_expired} = Inference.resolve(token)
254 264
    end
255 265
256
    test "a thread that has never minted authority is not reaped" do
257
      user = owner("never-minted")
258
      {:ok, thread} = Threads.open(user, "No authority yet")
266
    test "a thread left holding no authority is closed as spent, never as expired" do
267
      # The slot has to come back: nothing is coming to renew a grant, and an
268
      # open thread that can never work again would hold the ceiling forever.
269
      # What changed is the reason it is closed for — the budget ran out, which
270
      # is true, rather than a clock, which no longer exists.
271
      user = owner("left-open")
272
      elapsed_ttl()
273
      {:ok, thread} = Threads.open(user, "Deadline")
274
      {:ok, thread, _grant, _token} = Threads.mint_grant(thread)
259 275
260
      assert {0, 0} = Threads.reap_expired(user)
261
      assert Repo.get!(Thread, thread.id).status == "open"
276
      assert {1, 1} = Threads.reap_expired(user)
277
278
      reaped = Repo.get!(Thread, thread.id)
279
      assert reaped.status == "failed"
280
      assert reaped.error_code == "authority_spent"
281
      refute reaped.report =~ "expired"
262 282
    end
263 283
264 284
    test "reaping is idempotent" do
265 285
      user = owner("reap-twice")
266 286
      elapsed_ttl()
267
      {:ok, thread} = Threads.open(user, "Abandoned")
287
      {:ok, thread} = Threads.open(user, "Deadline")
268 288
      {:ok, _thread, _grant, _token} = Threads.mint_grant(thread)
269 289
270 290
      assert {1, 1} = Threads.reap_expired(user)
test/openagents_web/controllers/thread_controller_test.exs modified +43 -23

@@ -382,7 +382,37 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

382 382
      assert Map.drop(theirs, ["request_id"]) == Map.drop(absent, ["request_id"])
383 383
    end
384 384
385
    test "an expired grant is reported as expired without anyone revoking it", %{conn: conn} do
385
    test "a thread's authority carries no deadline, so time alone does not end it", %{
386
      conn: conn
387
    } do
388
      # What this replaces: the grant expired on a wall clock, the thread was
389
      # closed as `authority_expired`, and a coding session that was mid-work
390
      # was told to start a new one because an hour had passed.
391
      authenticated = put_chat_api_token(conn, "thread-no-clock")
392
393
      created =
394
        authenticated
395
        |> post(~p"/api/v3/threads", %{"objective" => "Outlive me."})
396
        |> json_response(201)
397
398
      body =
399
        authenticated
400
        |> get(~p"/api/v3/threads/#{created["thread"]["id"]}")
401
        |> json_response(200)
402
403
      assert body["grant"]["status"] == "active"
404
      assert body["thread"]["status"] == "open"
405
      assert is_nil(body["thread"]["error_code"])
406
      assert {:ok, _resolved} = Inference.resolve(created["grant"]["token"])
407
    end
408
409
    test "a thread left holding no authority reports it as spent, never as expired", %{
410
      conn: conn
411
    } do
412
      # The slot still has to come back — an open thread that can never work
413
      # again would hold the account's ceiling forever. What a reader is told
414
      # is that the authority was spent, which is true, rather than that it
415
      # expired, which is a concept this no longer has.
386 416
      authenticated = put_chat_api_token(conn, "thread-expiry-read")
387 417
      elapsed_ttl()
388 418

@@ -398,17 +428,14 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

398 428
399 429
      assert body["grant"]["status"] == "expired"
400 430
      assert body["thread"]["status"] == "failed"
401
      assert body["thread"]["error_code"] == "authority_expired"
402
      assert {:error, :grant_expired} = Inference.resolve(created["grant"]["token"])
431
      assert body["thread"]["error_code"] == "authority_spent"
432
      refute body["thread"]["report"] =~ "expired"
403 433
    end
404 434
405
    test "an expired thread releases the slot the cap counts", %{conn: conn} do
435
    test "the slot is released by revoking, not by waiting", %{conn: conn} do
406 436
      cap(1)
407
      restore_ttl = elapsed_ttl()
408 437
      authenticated = put_chat_api_token(conn, "thread-expiry-cap")
409 438
410
      # This thread's authority has already elapsed, but the row is open and
411
      # nobody has said anything about it.
412 439
      first =
413 440
        authenticated
414 441
        |> post(~p"/api/v3/threads", %{"objective" => "First."})

@@ -416,29 +443,22 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

416 443
417 444
      assert Threads.open_count(github_user("api-token-thread-expiry-cap")) == 1
418 445
419
      restore_ttl.()
420
421
      second =
446
      # Waiting does not free it. There is no clock to wait out.
447
      refused =
422 448
        authenticated
423 449
        |> post(~p"/api/v3/threads", %{"objective" => "Second."})
424
        |> json_response(201)
450
        |> json_response(429)
425 451
426
      retired =
427
        authenticated
428
        |> get(~p"/api/v3/threads/#{first["thread"]["id"]}")
429
        |> json_response(200)
452
      assert refused["code"] == "thread_quota_reached"
430 453
431
      assert retired["thread"]["status"] == "failed"
432
      assert retired["thread"]["error_code"] == "authority_expired"
454
      # Saying so does.
455
      _deleted = authenticated |> delete(~p"/api/v3/threads/#{first["thread"]["id"]}")
433 456
434
      # The second thread's authority has not elapsed, so it still holds the
435
      # only slot and the cap still bites.
436
      refused =
457
      second =
437 458
        authenticated
438
        |> post(~p"/api/v3/threads", %{"objective" => "Third."})
439
        |> json_response(429)
459
        |> post(~p"/api/v3/threads", %{"objective" => "Second."})
460
        |> json_response(201)
440 461
441
      assert refused["code"] == "thread_quota_reached"
442 462
      assert second["grant"]["token"] != first["grant"]["token"]
443 463
    end
444 464
  end

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