Give delegation a nested thread ledger

8928c4af50eb · AtlantisPleb · · parent 6d3bd90aa42e

Give delegation a nested thread ledger

N delegated children contending for the account's single chat turn had
nowhere durable to live. A child is now a thread that names its
parent, so delegation is a thread-plane operation and the receipts
have a home.

The contract, and what enforces it:

A child names an open, same-owner parent, and a parent that is
missing, terminal, or owned by someone else is refused with a typed
changeset error. A child counts toward the same admission cap, so a
parent cannot spawn its way past the account's joint exposure. A child
inherits its parent's visibility and cannot be opened wider, so the
parent's consent gate governs the whole subtree — the decision
THREAD-002 left open for children.

Budgets fence through the parent: a child grant is minted with
ceilings no larger than the parent's active grant has left in calls,
tokens, and cost, and a parent with nothing left refuses with
parent_authority_exhausted rather than lending what it does not have.

Spawning writes a thread.spawn record on the parent in the same
transaction as the child, and completion carries a typed report —
finish records the caller's type or defaults to outcome, cancel
records cancelled, the reaper records failure. A terminal thread has
both a report and its type, so completion is never inferred from
silence.

The database holds the shape: a self-parent is refused, the report
type is bounded, and the terminal-shape constraint now covers it. The
migration is reversible and backfills existing terminal rows.

THREAD-003 records all of this, with its evidence line and proof-index
row. This is the ledger only; the CLI registry and the web fleet view
become renderers over it.

Built by a Devin child through the openagents coder's delegate tool;
the proof-index row was added in review, and 129 thread and controller
tests re-run before landing.

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 348 · 2026-08-25T06:25:08.625090Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/threads.ex
  • modified lib/openagents/threads/thread.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825054906_add_parent_and_report_type_to_threads.exs
  • modified test/openagents/threads_test.exs

Diff

6 files changed, +496 -22

INVARIANTS.md modified +44

@@ -2363,6 +2363,49 @@ Evidence: `OpenAgents.Threads.fetch_readable/2`,

2363 2363
`test/openagents_web/thread_visibility_test.exs`, and
2364 2364
`test/openagents/threads/grant_token_reach_test.exs`.
2365 2365
2366
### THREAD-003 — Child threads are nested, budgeted, and typed
2367
2368
Status: Current
2369
2370
A child thread is a normal thread that names an open parent from the same
2371
account. Delegation therefore uses a nested transcript ledger: each child gets
2372
its own grant, its own budget, and its own terminal report, while the parent
2373
gains a durable `thread.spawn` record that links to it.
2374
2375
- **A child names an open, same-owner parent.** `OpenAgents.Threads.open/3`
2376
  accepts `parent_thread_id` and validates it. A parent that is missing,
2377
  malformed, terminal, or owned by another account is refused with a
2378
  `parent_thread_id` changeset error, and a `visibility` wider than the
2379
  parent is refused with a `visibility` changeset error.
2380
- **A child counts toward the same admission cap.** The cap already counts
2381
  every open thread, and a child is a normal open thread. Counting it keeps
2382
  a parent from spawning an unbounded number of concurrent children and
2383
  exceeding the account's joint credit-exposure bound.
2384
- **A child inherits its parent's visibility unless the caller narrows it.**
2385
  A child opened without a visibility takes the parent's visibility, and a
2386
  caller that names one cannot choose a tier wider than the parent's. The
2387
  parent's consent gate governs the whole subtree.
2388
- **A child budget is bounded by the parent's remaining allowance.** A child
2389
  grant is minted with ceilings no larger than the parent's active grant has
2390
  left in calls, total tokens, and cost, where `nil` means unbounded for that
2391
  dimension. If the parent has no active grant or no remaining allowance in a
2392
  bounded dimension, `OpenAgents.Threads.mint_grant/1` returns
2393
  `:parent_authority_exhausted`.
2394
- **Spawning records a `thread.spawn` event on the parent.** The parent and
2395
  child events are inserted in the same transaction as the child thread, and
2396
  the parent's `event_count` advances with it. No child event is mirrored on
2397
  the parent beyond this lifecycle record.
2398
- **Completion carries a typed report.** A terminal thread now stores
2399
  `report_type` alongside `report`. `OpenAgents.Threads.finish/2` records the
2400
  caller's `report_type` or defaults to `outcome`; `cancel/2` records
2401
  `cancelled`; the authority-spent reaper records `failure`. A terminal thread
2402
  always has both a report and its type, and neither is inferred from silence.
2403
2404
Evidence: `OpenAgents.Threads`, `OpenAgents.Threads.Thread`,
2405
`priv/repo/migrations/20260825054906_add_parent_and_report_type_to_threads.exs`,
2406
`test/openagents/threads_test.exs`, and
2407
`test/openagents/threads/visibility_test.exs`.
2408
2366 2409
## Tenant deployment control plane
2367 2410
2368 2411
### DEPLOYPLANE-001 — A deployment intent carries no authority

@@ -5229,6 +5272,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5229 5272
| SCV-001 | `test/openagents/scv/deployments_test.exs`, `test/openagents/dependency_boundary_test.exs` |
5230 5273
| THREAD-001 | `test/openagents/threads/grant_fence_test.exs`, `test/openagents/threads/grant_token_reach_test.exs`, `test/openagents/threads_test.exs`, `test/openagents/threads/credit_race_test.exs` |
5231 5274
| THREAD-002 | `test/openagents/threads/visibility_test.exs`, `test/openagents_web/thread_visibility_test.exs`, `test/openagents/threads/grant_token_reach_test.exs` |
5275
| THREAD-003 | `test/openagents/threads_test.exs`, `test/openagents/threads/visibility_test.exs` |
5232 5276
| OUTCOME-001 | `test/openagents/accepted_outcome_test.exs`, `test/openagents/issues/completion_claims_test.exs`, `test/openagents_web/controllers/issue_completion_claim_controller_test.exs` |
5233 5277
| DEPLOYPLANE-001 | `test/openagents/deployments_test.exs`, `test/openagents_web/controllers/deployment_controller_test.exs`, `test/openagents_web/api_route_authority_test.exs` |
5234 5278
| DEPLOYPLANE-002 | `test/openagents/deployments_test.exs` |
lib/openagents/threads.ex modified +208 -17

@@ -127,7 +127,11 @@ defmodule OpenAgents.Threads do

127 127
  @spec open_and_mint(User.t() | Visitor.t(), String.t(), keyword()) ::
128 128
          {:ok, Thread.t(), Grant.t(), String.t()}
129 129
          | {:error,
130
             :thread_quota_reached | :thread_terminal | :credit_exhausted | Ecto.Changeset.t()}
130
             :thread_quota_reached
131
             | :thread_terminal
132
             | :credit_exhausted
133
             | :parent_authority_exhausted
134
             | Ecto.Changeset.t()}
131 135
  def open_and_mint(owner, objective, options \\ []) do
132 136
    with {:ok, thread} <- open(owner, objective, options) do
133 137
      case mint_grant(thread) do

@@ -151,15 +155,16 @@ defmodule OpenAgents.Threads do

151 155
152 156
  defp insert_thread(visitor_id, objective, options) do
153 157
    now = DateTime.utc_now()
158
    parent_id = Keyword.get(options, :parent_thread_id)
154 159
155
    attributes = %{
160
    base_attributes = %{
156 161
      objective: objective,
157 162
      repository: Keyword.get(options, :repository),
158
      visibility: Keyword.get(options, :visibility) || Thread.default_visibility(),
159 163
      model: Keyword.get(options, :model) || Models.default_id(),
160 164
      reasoning_effort:
161 165
        OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, @default_reasoning)),
162
      permission_profile: Keyword.get(options, :permission_profile, @default_permission_profile)
166
      permission_profile: Keyword.get(options, :permission_profile, @default_permission_profile),
167
      parent_thread_id: parent_id
163 168
    }
164 169
165 170
    Multi.new()

@@ -176,14 +181,39 @@ defmodule OpenAgents.Threads do

176 181
      # `nil` is unbounded. A session no longer destroys its thread on the way
177 182
      # out, so a count of open threads is a count of every session the account
178 183
      # has ever run — and refusing the ninth would refuse the work rather than
179
      # bound it.
184
      # bound it. A child thread is a normal open thread for this count, because
185
      # every open thread holds a grant slot and a transcript, and capping the
186
      # total number of open threads caps the account's joint credit exposure.
180 187
      if ceiling != nil and open_count(visitor_id) >= ceiling do
181 188
        {:error, :thread_quota_reached}
182 189
      else
183
        {:ok, :admitted}
190
        with {:ok, parent} <- load_parent(repo, parent_id, visitor_id) do
191
          {:ok, parent}
192
        end
193
      end
194
    end)
195
    |> Multi.run(:resolved, fn _repo, %{admission: parent} ->
196
      requested = Keyword.get(options, :visibility)
197
198
      visibility =
199
        if parent,
200
          do: requested || parent.visibility,
201
          else: requested || Thread.default_visibility()
202
203
      if parent != nil and wider_visibility?(visibility, parent.visibility) do
204
        {:error,
205
         add_error(
206
           %Thread{},
207
           :visibility,
208
           "cannot be wider than the parent thread's visibility"
209
         )}
210
      else
211
        {:ok, Map.put(base_attributes, :visibility, visibility)}
184 212
      end
185 213
    end)
186
    |> Multi.insert(:thread, Thread.open_changeset(attributes, visitor_id, now))
214
    |> Multi.insert(:thread, fn %{resolved: attributes} ->
215
      Thread.open_changeset(attributes, visitor_id, now)
216
    end)
187 217
    |> Multi.run(:opened_event, fn _repo, %{thread: thread} ->
188 218
      insert_event(
189 219
        thread,

@@ -192,12 +222,30 @@ defmodule OpenAgents.Threads do

192 222
        now
193 223
      )
194 224
    end)
225
    |> Multi.run(:spawn_event, fn _repo, %{admission: parent, thread: thread} ->
226
      if parent do
227
        insert_event(
228
          parent,
229
          "thread.spawn",
230
          %{
231
            "child_thread_id" => thread.id,
232
            "child_objective" => objective,
233
            "visibility" => thread.visibility
234
          },
235
          now
236
        )
237
      else
238
        {:ok, nil}
239
      end
240
    end)
195 241
    # Widening is an act, so it leaves a record rather than only a column value
196 242
    # (THREAD-002). The event is written only when the opener asked for a tier
197 243
    # wider than owner-only: a default thread was never widened, and an event
198
    # saying so on every open would make the record meaningless.
199
    |> Multi.run(:widened_event, fn _repo, %{thread: thread} ->
200
      if Thread.wide?(thread) do
244
    # saying so on every open would make the record meaningless. A child thread
245
    # inherits its parent\'s visibility, so its opening does not need its own
246
    # visibility act unless the caller explicitly narrows or widens it.
247
    |> Multi.run(:widened_event, fn _repo, %{admission: parent, thread: thread} ->
248
      if is_nil(parent) and Thread.wide?(thread) do
201 249
        insert_event(
202 250
          thread,
203 251
          "thread.visibility_set",

@@ -212,11 +260,29 @@ defmodule OpenAgents.Threads do

212 260
      appended = if widened, do: 2, else: 1
213 261
      Thread.event_count_changeset(thread, thread.event_count + appended)
214 262
    end)
263
    |> Multi.run(:parent_counted, fn _repo, %{admission: parent, spawn_event: spawn} ->
264
      if parent && spawn do
265
        parent
266
        |> Thread.event_count_changeset(parent.event_count + 1)
267
        |> Repo.update()
268
      else
269
        {:ok, nil}
270
      end
271
    end)
215 272
    |> Repo.transaction()
216 273
    |> case do
217
      {:ok, %{counted: thread}} -> {:ok, thread}
218
      {:error, :admission, :thread_quota_reached, _changes} -> {:error, :thread_quota_reached}
219
      {:error, _step, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset}
274
      {:ok, %{counted: thread, spawn_event: spawn}} ->
275
        if is_struct(spawn, Event) do
276
          broadcast(spawn)
277
        end
278
279
        {:ok, thread}
280
281
      {:error, :admission, :thread_quota_reached, _changes} ->
282
        {:error, :thread_quota_reached}
283
284
      {:error, _step, %Ecto.Changeset{} = changeset, _changes} ->
285
        {:error, changeset}
220 286
    end
221 287
  end
222 288

@@ -453,6 +519,10 @@ defmodule OpenAgents.Threads do

453 519
454 520
  defp topic(thread_id), do: "thread:" <> thread_id
455 521
522
  defp broadcast(%Event{} = event) do
523
    Phoenix.PubSub.broadcast(OpenAgents.PubSub, topic(event.thread_id), {:thread_event, event})
524
  end
525
456 526
  @doc """
457 527
  Mint model authority for a thread.
458 528

@@ -462,6 +532,12 @@ defmodule OpenAgents.Threads do

462 532
  id; `OpenAgents.Inference.Models.fetch/1` resolves that spelling, and
463 533
  anything else it cannot route is refused rather than quietly replaced.
464 534
535
  For a child thread, the grant is minted against the parent's remaining
536
  allowance: the child can spend no more than the parent has left. A child
537
  whose parent holds no active grant, or whose parent has no remaining calls,
538
  tokens, or cost, is refused `:parent_authority_exhausted` rather than minted
539
  authority it cannot use.
540
465 541
  This is the fence. In one transaction: the thread is locked and refused
466 542
  unless it is open, every active grant naming it is revoked, `generation` is
467 543
  bumped, and a fresh grant is minted against the thread — never against a

@@ -469,7 +545,11 @@ defmodule OpenAgents.Threads do

469 545
  """
470 546
  @spec mint_grant(Thread.t()) ::
471 547
          {:ok, Thread.t(), Grant.t(), String.t()}
472
          | {:error, :thread_terminal | :credit_exhausted | Ecto.Changeset.t()}
548
          | {:error,
549
             :thread_terminal
550
             | :credit_exhausted
551
             | :parent_authority_exhausted
552
             | Ecto.Changeset.t()}
473 553
  def mint_grant(%Thread{} = thread) do
474 554
    Repo.transaction(fn ->
475 555
      case locked(thread.id) do

@@ -483,7 +563,7 @@ defmodule OpenAgents.Threads do

483 563
          _revoked = Inference.revoke_active_for_thread(current.id)
484 564
485 565
          with {:ok, fenced} <- current |> Thread.generation_changeset() |> Repo.update(),
486
               {:ok, ceilings} <- ceilings(fenced.owner_visitor_id),
566
               {:ok, ceilings} <- grant_ceilings(fenced),
487 567
               {:ok, grant, token} <-
488 568
                 Inference.mint(%{
489 569
                   owner_visitor_id: fenced.owner_visitor_id,

@@ -508,8 +588,8 @@ defmodule OpenAgents.Threads do

508 588
  end
509 589
510 590
  @doc """
511
  End a thread with its bounded report, revoking its authority in the same
512
  transaction. Idempotent refusal on an already-terminal thread.
591
  End a thread with its bounded, typed report, revoking its authority in the
592
  same transaction. Idempotent refusal on an already-terminal thread.
513 593
  """
514 594
  @spec finish(Thread.t(), map()) ::
515 595
          {:ok, Thread.t()} | {:error, :thread_terminal | Ecto.Changeset.t()}

@@ -520,6 +600,7 @@ defmodule OpenAgents.Threads do

520 600
      status: Map.get(result, :status) || Map.get(result, "status") || "succeeded",
521 601
      report: report,
522 602
      report_digest: digest(report),
603
      report_type: Map.get(result, :report_type) || Map.get(result, "report_type") || "outcome",
523 604
      usage: Map.get(result, :usage) || Map.get(result, "usage") || %{},
524 605
      error_code: Map.get(result, :error_code) || Map.get(result, "error_code"),
525 606
      completed_at: DateTime.utc_now()

@@ -536,6 +617,7 @@ defmodule OpenAgents.Threads do

536 617
      status: "cancelled",
537 618
      report: report,
538 619
      report_digest: digest(report),
620
      report_type: "cancelled",
539 621
      error_code: "cancelled",
540 622
      completed_at: DateTime.utc_now()
541 623
    })

@@ -717,11 +799,120 @@ defmodule OpenAgents.Threads do

717 799
      status: "failed",
718 800
      report: report,
719 801
      report_digest: digest(report),
802
      report_type: "failure",
720 803
      error_code: "authority_spent",
721 804
      completed_at: DateTime.utc_now()
722 805
    }
723 806
  end
724 807
808
  defp grant_ceilings(%Thread{parent_thread_id: nil} = thread),
809
    do: ceilings(thread.owner_visitor_id)
810
811
  defp grant_ceilings(%Thread{parent_thread_id: parent_id}) do
812
    case Repo.one(from t in Thread, where: t.id == ^parent_id, lock: "FOR UPDATE") do
813
      %Thread{status: "open"} = parent ->
814
        grant =
815
          Repo.one(
816
            from g in Grant,
817
              where: g.thread_id == ^parent.id and g.status == "active",
818
              lock: "FOR UPDATE"
819
          )
820
821
        if grant,
822
          do: child_ceilings_from_grant(grant),
823
          else: {:error, :parent_authority_exhausted}
824
825
      _ ->
826
        {:error, :parent_authority_exhausted}
827
    end
828
  end
829
830
  defp child_ceilings_from_grant(%Grant{} = grant) do
831
    remaining_calls = remaining(grant.call_count, grant.max_calls)
832
    remaining_tokens = remaining(to_integer(grant.usage["total_tokens"]), grant.max_total_tokens)
833
834
    remaining_cost =
835
      remaining(to_integer(grant.usage["estimated_cost_microusd"]), grant.max_cost_microusd)
836
837
    remaining_ttl = remaining_seconds(grant.expires_at)
838
839
    if exhausted_dimension?(remaining_calls) or exhausted_dimension?(remaining_tokens) or
840
         exhausted_dimension?(remaining_cost) or exhausted_dimension?(remaining_ttl) do
841
      {:error, :parent_authority_exhausted}
842
    else
843
      base = ceilings()
844
845
      {:ok,
846
       %{
847
         max_total_tokens: min_option(base.max_total_tokens, remaining_tokens),
848
         max_calls: min_option(base.max_calls, remaining_calls),
849
         max_cost_microusd: min_option(base.max_cost_microusd, remaining_cost),
850
         ttl_seconds: min_option(base.ttl_seconds, remaining_ttl)
851
       }}
852
    end
853
  end
854
855
  defp exhausted_dimension?(nil), do: false
856
  defp exhausted_dimension?(value) when value <= 0, do: true
857
  defp exhausted_dimension?(_), do: false
858
859
  defp remaining(_spent, nil), do: nil
860
  defp remaining(spent, limit), do: limit - spent
861
862
  defp min_option(nil, b), do: b
863
  defp min_option(a, nil), do: a
864
  defp min_option(a, b), do: min(a, b)
865
866
  defp remaining_seconds(nil), do: nil
867
868
  defp remaining_seconds(expires_at) do
869
    DateTime.diff(expires_at, DateTime.utc_now(), :second)
870
    |> max(0)
871
  end
872
873
  defp to_integer(value) when is_integer(value), do: value
874
  defp to_integer(value) when is_float(value), do: trunc(value)
875
876
  defp to_integer(value) when is_binary(value) do
877
    case Integer.parse(value) do
878
      {int, _} -> int
879
      :error -> 0
880
    end
881
  end
882
883
  defp to_integer(_), do: 0
884
885
  defp load_parent(_repo, nil, _visitor_id), do: {:ok, nil}
886
887
  defp load_parent(repo, parent_id, visitor_id) do
888
    case Ecto.UUID.cast(parent_id) do
889
      {:ok, id} ->
890
        case repo.one(
891
               from t in Thread,
892
                 where: t.id == ^id and t.owner_visitor_id == ^visitor_id and t.status == "open"
893
             ) do
894
          %Thread{} = parent ->
895
            {:ok, parent}
896
897
          nil ->
898
            {:error, add_error(%Thread{}, :parent_thread_id, "not a valid, open parent thread")}
899
        end
900
901
      :error ->
902
        {:error, add_error(%Thread{}, :parent_thread_id, "is not a valid UUID")}
903
    end
904
  end
905
906
  defp add_error(%Thread{} = data, field, message) do
907
    Ecto.Changeset.add_error(Ecto.Changeset.change(data, %{}), field, message)
908
  end
909
910
  defp wider_visibility?(child, parent) do
911
    child_rank = Enum.find_index(Thread.visibilities(), &(&1 == child))
912
    parent_rank = Enum.find_index(Thread.visibilities(), &(&1 == parent))
913
    child_rank != nil and parent_rank != nil and child_rank > parent_rank
914
  end
915
725 916
  defp setting(key, default), do: Application.get_env(:openagents, key, default)
726 917
727 918
  defp locked(thread_id) do
lib/openagents/threads/thread.ex modified +19 -4

@@ -56,7 +56,9 @@ defmodule OpenAgents.Threads.Thread do

56 56
    field :usage, :map, default: %{}
57 57
    field :started_at, :utc_datetime_usec
58 58
    field :completed_at, :utc_datetime_usec
59
    field :report_type, :string
59 60
    has_many :events, Event, foreign_key: :thread_id
61
    belongs_to :parent, __MODULE__, foreign_key: :parent_thread_id
60 62
    timestamps()
61 63
  end
62 64

@@ -119,7 +121,8 @@ defmodule OpenAgents.Threads.Thread do

119 121
      :reasoning_effort,
120 122
      :permission_profile,
121 123
      :repository,
122
      :visibility
124
      :visibility,
125
      :parent_thread_id
123 126
    ])
124 127
    |> put_change(:owner_visitor_id, owner_visitor_id)
125 128
    |> put_change(:status, "open")

@@ -139,6 +142,8 @@ defmodule OpenAgents.Threads.Thread do

139 142
    |> validate_inclusion(:permission_profile, @permission_profiles)
140 143
    |> validate_inclusion(:visibility, @visibilities)
141 144
    |> foreign_key_constraint(:owner_visitor_id)
145
    |> foreign_key_constraint(:parent_thread_id)
146
    |> check_constraint(:parent_thread_id, name: :threads_no_self_parent)
142 147
    |> check_constraint(:status, name: :threads_status_check)
143 148
    |> check_constraint(:objective, name: :threads_objective_bound_check)
144 149
    |> check_constraint(:repository, name: :threads_repository_bound_check)

@@ -161,17 +166,27 @@ defmodule OpenAgents.Threads.Thread do

161 166
    |> check_constraint(:event_count, name: :threads_event_count_nonnegative_check)
162 167
  end
163 168
164
  @doc "The terminal receipt. A thread ends once and carries a report when it does."
169
  @doc "The terminal receipt. A thread ends once and carries a typed report when it does."
165 170
  def terminal_changeset(%__MODULE__{} = thread, attributes) do
166 171
    thread
167
    |> cast(attributes, [:status, :report, :report_digest, :usage, :error_code, :completed_at])
168
    |> validate_required([:status, :report, :report_digest, :completed_at])
172
    |> cast(attributes, [
173
      :status,
174
      :report,
175
      :report_digest,
176
      :report_type,
177
      :usage,
178
      :error_code,
179
      :completed_at
180
    ])
181
    |> validate_required([:status, :report, :report_digest, :report_type, :completed_at])
169 182
    |> validate_inclusion(:status, @terminal_statuses)
170 183
    |> validate_length(:report, min: 1, max: @objective_bytes, count: :bytes)
171 184
    |> validate_format(:report_digest, ~r/\Asha256:[0-9a-f]{64}\z/)
185
    |> validate_length(:report_type, max: 80)
172 186
    |> validate_length(:error_code, max: 80)
173 187
    |> check_constraint(:status, name: :threads_status_check)
174 188
    |> check_constraint(:report, name: :threads_report_bound_check)
189
    |> check_constraint(:report_type, name: :threads_report_type_bound_check)
175 190
    |> check_constraint(:completed_at, name: :threads_terminal_shape_check)
176 191
  end
177 192
end
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -297,7 +297,8 @@

297 297
    20260824230007,
298 298
    20260824230730,
299 299
    20260824231951,
300
    20260825024500
300
    20260825024500,
301
    20260825054906
301 302
  ],
302 303
  "required_tables": [
303 304
    "users",
priv/repo/migrations/20260825054906_add_parent_and_report_type_to_threads.exs added +65

@@ -0,0 +1,65 @@

1
defmodule OpenAgents.Repo.Migrations.AddParentAndReportTypeToThreads do
2
  @moduledoc """
3
  A thread may name a parent thread and a terminal thread now carries a typed
4
  report. The parent reference is nullable: a thread without a parent is a root
5
  thread. A child thread is otherwise a normal thread, so the owner, budget,
6
  and lifecycle are all the same. The report type is required once a thread
7
  terminates and null while it is open.
8
  """
9
10
  use Ecto.Migration
11
12
  def up do
13
    alter table(:threads) do
14
      add :parent_thread_id,
15
          references(:threads, type: :binary_id, on_delete: :delete_all)
16
17
      add :report_type, :string
18
    end
19
20
    create index(:threads, [:parent_thread_id])
21
22
    execute("""
23
    UPDATE threads
24
    SET report_type = 'outcome'
25
    WHERE status <> 'open' AND report_type IS NULL
26
    """)
27
28
    drop constraint(:threads, :threads_terminal_shape_check)
29
30
    create constraint(:threads, :threads_terminal_shape_check,
31
             check: """
32
             (status = 'open' AND completed_at IS NULL AND report IS NULL AND report_type IS NULL)
33
             OR (status <> 'open' AND completed_at IS NOT NULL AND report IS NOT NULL AND report_type IS NOT NULL)
34
             """
35
           )
36
37
    create constraint(:threads, :threads_no_self_parent,
38
             check: "parent_thread_id IS NULL OR parent_thread_id <> id"
39
           )
40
41
    create constraint(:threads, :threads_report_type_bound_check,
42
             check: "report_type IS NULL OR octet_length(report_type) BETWEEN 1 AND 80"
43
           )
44
  end
45
46
  def down do
47
    drop constraint(:threads, :threads_report_type_bound_check)
48
    drop constraint(:threads, :threads_no_self_parent)
49
    drop constraint(:threads, :threads_terminal_shape_check)
50
51
    create constraint(:threads, :threads_terminal_shape_check,
52
             check: """
53
             (status = 'open' AND completed_at IS NULL AND report IS NULL)
54
             OR (status <> 'open' AND completed_at IS NOT NULL AND report IS NOT NULL)
55
             """
56
           )
57
58
    drop index(:threads, [:parent_thread_id])
59
60
    alter table(:threads) do
61
      remove :report_type
62
      remove :parent_thread_id
63
    end
64
  end
65
end
test/openagents/threads_test.exs modified +158

@@ -332,6 +332,164 @@ defmodule OpenAgents.ThreadsTest do

332 332
    end
333 333
  end
334 334
335
  defp set_config(key, value) do
336
    previous = Application.get_env(:openagents, key)
337
    Application.put_env(:openagents, key, value)
338
339
    on_exit(fn ->
340
      Application.put_env(:openagents, key, previous)
341
    end)
342
  end
343
344
  describe "delegated child threads" do
345
    test "a child thread names its parent and stays on the same account" do
346
      user = owner("child-parent")
347
      {:ok, parent, _grant, _token} = Threads.open_and_mint(user, "Parent")
348
      {:ok, child} = Threads.open(user, "Child", parent_thread_id: parent.id)
349
350
      assert child.parent_thread_id == parent.id
351
      assert child.owner_visitor_id == parent.owner_visitor_id
352
    end
353
354
    test "a child cannot name another account's parent" do
355
      mine = owner("child-owner-mine")
356
      theirs = owner("child-owner-theirs")
357
      {:ok, parent, _grant, _token} = Threads.open_and_mint(mine, "Parent")
358
359
      assert {:error, changeset} =
360
               Threads.open(theirs, "Child", parent_thread_id: parent.id)
361
362
      assert %{parent_thread_id: _} = errors_on(changeset)
363
    end
364
365
    test "a child cannot name a terminal parent" do
366
      user = owner("child-terminal-parent")
367
      {:ok, parent, _grant, _token} = Threads.open_and_mint(user, "Parent")
368
      {:ok, parent} = Threads.finish(parent, %{report: "Done."})
369
370
      assert {:error, changeset} =
371
               Threads.open(user, "Child", parent_thread_id: parent.id)
372
373
      assert %{parent_thread_id: _} = errors_on(changeset)
374
    end
375
376
    test "a child is refused when the parent holds no active grant" do
377
      user = owner("child-no-grant")
378
      {:ok, parent} = Threads.open(user, "Parent")
379
380
      assert {:error, :parent_authority_exhausted} =
381
               Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
382
    end
383
384
    test "a child inherits its parent's visibility" do
385
      user = owner("child-visibility-inherit")
386
      {:ok, parent} = Threads.open(user, "Parent", visibility: "ledger")
387
      {:ok, child} = Threads.open(user, "Child", parent_thread_id: parent.id)
388
389
      assert child.visibility == "ledger"
390
    end
391
392
    test "a child cannot be opened wider than its parent" do
393
      user = owner("child-visibility-wide")
394
      {:ok, parent} = Threads.open(user, "Parent", visibility: "dark")
395
396
      assert {:error, changeset} =
397
               Threads.open(user, "Child",
398
                 parent_thread_id: parent.id,
399
                 visibility: "ledger"
400
               )
401
402
      assert %{visibility: _} = errors_on(changeset)
403
    end
404
405
    test "a child counts toward the admission cap" do
406
      cap(2)
407
      user = owner("child-cap")
408
      {:ok, parent} = Threads.open(user, "Parent")
409
      assert {:ok, _child} = Threads.open(user, "Child", parent_thread_id: parent.id)
410
      assert {:error, :thread_quota_reached} = Threads.open(user, "Third")
411
    end
412
413
    test "spawning a child appends a thread.spawn event to the parent transcript" do
414
      user = owner("child-spawn")
415
      {:ok, parent, _grant, _token} = Threads.open_and_mint(user, "Parent")
416
      {:ok, child} = Threads.open(user, "Child", parent_thread_id: parent.id)
417
418
      parent = Threads.get_for_user(user, parent.id)
419
      events = Threads.list_events(parent)
420
421
      assert [%Event{event_type: "thread.opened"}, %Event{event_type: "thread.spawn"}] = events
422
      spawn = List.last(events)
423
      assert spawn.payload["child_thread_id"] == child.id
424
      assert parent.event_count == 2
425
    end
426
  end
427
428
  describe "child thread ceilings" do
429
    test "a child grant is ceiled at the parent's remaining calls" do
430
      set_config(:thread_grant_max_calls, 5)
431
      set_config(:thread_grant_max_total_tokens, nil)
432
      user = owner("child-calls")
433
      {:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
434
      {:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 1})
435
      {:ok, spent} = Inference.record_usage(spent, %{"output_tokens" => 1})
436
      assert spent.call_count == 2
437
438
      {:ok, _child, child_grant, _token} =
439
        Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
440
441
      assert child_grant.max_calls == 3
442
    end
443
444
    test "a child grant is ceiled at the parent's remaining tokens" do
445
      set_config(:thread_grant_max_total_tokens, 100)
446
      set_config(:thread_grant_max_calls, nil)
447
      user = owner("child-tokens")
448
      {:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
449
      {:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 30})
450
      assert spent.usage["total_tokens"] == 30
451
452
      {:ok, _child, child_grant, _token} =
453
        Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
454
455
      assert child_grant.max_total_tokens == 70
456
    end
457
458
    test "a child grant is ceiled at the parent's remaining cost" do
459
      set_config(:account_credit_microusd, 100_000)
460
      set_config(:visitor_credit_microusd, 100_000)
461
      set_config(:thread_grant_max_calls, nil)
462
      set_config(:thread_grant_max_total_tokens, nil)
463
      user = owner("child-cost")
464
      {:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
465
      {:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => 1000})
466
      assert spent.usage["estimated_cost_microusd"] == 10_000
467
468
      {:ok, _child, child_grant, _token} =
469
        Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
470
471
      assert child_grant.max_cost_microusd == 90_000
472
    end
473
  end
474
475
  describe "typed thread reports" do
476
    test "completing a thread records the requested report type" do
477
      user = owner("typed-report")
478
      {:ok, thread} = Threads.open(user, "Objective")
479
      {:ok, finished} = Threads.finish(thread, %{report: "Done.", report_type: "result"})
480
481
      assert finished.report_type == "result"
482
    end
483
484
    test "finish defaults the report type when the caller names none" do
485
      user = owner("typed-report-default")
486
      {:ok, thread} = Threads.open(user, "Objective")
487
      {:ok, finished} = Threads.finish(thread, %{report: "Done."})
488
489
      assert finished.report_type == "outcome"
490
    end
491
  end
492
335 493
  defp cap(limit) do
336 494
    previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
337 495
    Application.put_env(:openagents, :maximum_open_threads_per_account, limit)

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