Let a thread name the issue it is working

8906ed6efa3f · AtlantisPleb · · parent 66556a38d716

Let a thread name the issue it is working

An issue and the agent work acting on it had no link, so "what has
touched this issue" had no answer and the obvious fix — a second
record tracking agent work per issue — was the thing to avoid.

A thread may now name an issue, and that is the whole link. The thread
stays the durable work record and the issue keeps owning issue state,
so nothing is duplicated: deleting an issue nulls the reference and
leaves the thread standing, because the work happened whether or not
the issue survives.

`list_for_issue/2` answers the question, composing the reader rule
threads already have rather than restating it — a caller sees the
threads they could already see and no others, with a test for the
stranger case.

ISSUE-001 is untouched: a commit still closes an issue only from the
default branch, and this adds no second path to closing anything.

The grant-token reach enumeration classified the new export as it
landed, which is what that proof is for.

Built by a Devin child through the openagents coder's delegate tool;
the full suite is green at 4,476.

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 361 · 2026-08-25T10:44:29.774354Z

Changed files

  • modified lib/openagents/threads.ex
  • modified lib/openagents/threads/thread.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825101934_add_issue_reference_to_threads.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents/threads/visibility_test.exs

Diff

9 files changed, +175 -12

lib/openagents/threads.ex modified +39 -10

@@ -76,6 +76,7 @@ defmodule OpenAgents.Threads do

76 76
  alias OpenAgents.Conversations.Visitor
77 77
  alias OpenAgents.Inference
78 78
  alias OpenAgents.Inference.{Credit, Grant, Models}
79
  alias OpenAgents.Issues.Issue
79 80
  alias OpenAgents.Repo
80 81
  alias OpenAgents.Threads.Event
81 82
  alias OpenAgents.Threads.Thread

@@ -164,7 +165,8 @@ defmodule OpenAgents.Threads do

164 165
      reasoning_effort:
165 166
        OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, @default_reasoning)),
166 167
      permission_profile: Keyword.get(options, :permission_profile, @default_permission_profile),
167
      parent_thread_id: parent_id
168
      parent_thread_id: parent_id,
169
      issue_id: Keyword.get(options, :issue_id)
168 170
    }
169 171
170 172
    Multi.new()

@@ -325,9 +327,7 @@ defmodule OpenAgents.Threads do

325 327
  not invent an anonymous one (THREAD-002).
326 328
  """
327 329
  @spec fetch_readable(User.t(), String.t()) :: {:ok, Thread.t(), :owner | :reader} | :error
328
  def fetch_readable(%User{id: user_id}, thread_id) when is_binary(thread_id) do
329
    wide = Thread.wide_visibilities()
330
330
  def fetch_readable(%User{} = user, thread_id) when is_binary(thread_id) do
331 331
    with {:ok, id} <- Ecto.UUID.cast(thread_id),
332 332
         {%Thread{} = thread, owner_user_id} <-
333 333
           Repo.one(

@@ -335,11 +335,11 @@ defmodule OpenAgents.Threads do

335 335
               join: v in Visitor,
336 336
               on: v.id == t.owner_visitor_id,
337 337
               where: t.id == ^id,
338
               where: v.user_id == ^user_id or t.visibility in ^wide,
339 338
               select: {t, v.user_id}
340 339
             )
340
             |> readable_for(user)
341 341
           ) do
342
      {:ok, thread, if(owner_user_id == user_id, do: :owner, else: :reader)}
342
      {:ok, thread, if(owner_user_id == user.id, do: :owner, else: :reader)}
343 343
    else
344 344
      _unreadable -> :error
345 345
    end

@@ -369,6 +369,32 @@ defmodule OpenAgents.Threads do

369 369
    |> Repo.all()
370 370
  end
371 371
372
  @doc """
373
  The threads that name `issue` and that `reader` may read, newest first.
374
375
  A thread is returned when the reader is its owner or the thread's
376
  visibility is a wide tier, because a thread's transcript is private until
377
  its owner says otherwise (THREAD-002).
378
  """
379
  @spec list_for_issue(Issue.t(), User.t()) :: [Thread.t()]
380
  def list_for_issue(%Issue{id: issue_id}, %User{} = reader) do
381
    from(t in Thread,
382
      join: v in Visitor,
383
      on: v.id == t.owner_visitor_id,
384
      where: t.issue_id == ^issue_id,
385
      order_by: [desc: t.inserted_at, desc: t.id],
386
      limit: ^@maximum_listed
387
    )
388
    |> readable_for(reader)
389
    |> Repo.all()
390
  end
391
392
  defp readable_for(query, %User{id: user_id}) do
393
    wide = Thread.wide_visibilities()
394
395
    where(query, [t, v], v.user_id == ^user_id or t.visibility in ^wide)
396
  end
397
372 398
  defp in_repository(query, repository) when is_binary(repository),
373 399
    do: from(t in query, where: t.repository == ^repository)
374 400

@@ -677,8 +703,6 @@ defmodule OpenAgents.Threads do

677 703
    end
678 704
  end
679 705
680
  @doc "How many threads one account may hold open at once, or `nil` for no limit."
681
  @spec maximum_open_per_account() :: pos_integer() | nil
682 706
  @doc """
683 707
  What a thread has spent, summed across every grant it has ever held.
684 708

@@ -710,8 +734,11 @@ defmodule OpenAgents.Threads do

710 734
    usage =
711 735
      Enum.reduce(grants, %{}, fn {_calls, usage}, acc ->
712 736
        Enum.reduce(usage || %{}, acc, fn
713
          {key, value}, inner when is_integer(value) -> Map.update(inner, key, value, &(&1 + value))
714
          {_key, _value}, inner -> inner
737
          {key, value}, inner when is_integer(value) ->
738
            Map.update(inner, key, value, &(&1 + value))
739
740
          {_key, _value}, inner ->
741
            inner
715 742
        end)
716 743
      end)
717 744

@@ -722,6 +749,8 @@ defmodule OpenAgents.Threads do

722 749
    }
723 750
  end
724 751
752
  @doc "How many threads one account may hold open at once, or `nil` for no limit."
753
  @spec maximum_open_per_account() :: pos_integer() | nil
725 754
  def maximum_open_per_account, do: setting(:maximum_open_threads_per_account, nil)
726 755
727 756
  @doc "How many threads this account currently holds open."
lib/openagents/threads/thread.ex modified +5 -1

@@ -19,6 +19,7 @@ defmodule OpenAgents.Threads.Thread do

19 19
  import Ecto.Changeset
20 20
21 21
  alias OpenAgents.Conversations.Visitor
22
  alias OpenAgents.Issues.Issue
22 23
  alias OpenAgents.Threads.Event
23 24
24 25
  @primary_key {:id, :binary_id, autogenerate: true}

@@ -59,6 +60,7 @@ defmodule OpenAgents.Threads.Thread do

59 60
    field :report_type, :string
60 61
    has_many :events, Event, foreign_key: :thread_id
61 62
    belongs_to :parent, __MODULE__, foreign_key: :parent_thread_id
63
    belongs_to :issue, Issue, type: :id
62 64
    timestamps()
63 65
  end
64 66

@@ -122,7 +124,8 @@ defmodule OpenAgents.Threads.Thread do

122 124
      :permission_profile,
123 125
      :repository,
124 126
      :visibility,
125
      :parent_thread_id
127
      :parent_thread_id,
128
      :issue_id
126 129
    ])
127 130
    |> put_change(:owner_visitor_id, owner_visitor_id)
128 131
    |> put_change(:status, "open")

@@ -143,6 +146,7 @@ defmodule OpenAgents.Threads.Thread do

143 146
    |> validate_inclusion(:visibility, @visibilities)
144 147
    |> foreign_key_constraint(:owner_visitor_id)
145 148
    |> foreign_key_constraint(:parent_thread_id)
149
    |> foreign_key_constraint(:issue_id)
146 150
    |> check_constraint(:parent_thread_id, name: :threads_no_self_parent)
147 151
    |> check_constraint(:status, name: :threads_status_check)
148 152
    |> check_constraint(:objective, name: :threads_objective_bound_check)
lib/openagents_web/controllers/api_extension_controller.ex modified +29

@@ -54,6 +54,27 @@ defmodule OpenAgentsWeb.ApiExtensionController do

54 54
    }
55 55
  }
56 56
57
  @thread %{
58
    "type" => "object",
59
    "description" =>
60
      "One thread that named this issue. A thread is the durable work " <>
61
        "record; it is listed here, not stored on the issue.",
62
    "properties" => %{
63
      "id" => %{"type" => "string"},
64
      "status" => %{
65
        "type" => "string",
66
        "enum" => OpenAgents.Threads.Thread.statuses()
67
      },
68
      "visibility" => %{
69
        "type" => "string",
70
        "enum" => OpenAgents.Threads.Thread.visibilities()
71
      },
72
      "inserted_at" => %{"type" => "string"},
73
      "updated_at" => %{"type" => "string"},
74
      "url" => %{"type" => "string"}
75
    }
76
  }
77
57 78
  @work_attempt %{
58 79
    "type" => "object",
59 80
    "description" =>

@@ -296,6 +317,14 @@ defmodule OpenAgentsWeb.ApiExtensionController do

296 317
              "state, branch, exact commit, and timestamps. Prompts, " <>
297 318
              "conversations, reports, and credentials stay out."
298 319
        },
320
        "threads" => %{
321
          "type" => "array",
322
          "items" => @thread,
323
          "description" =>
324
            "Every thread that named this issue and that the reader may read, " <>
325
              "newest first. A thread is the durable work record; it is " <>
326
              "listed here, not stored as a second work record on the issue."
327
        },
299 328
        "evidence" => %{
300 329
          "type" => "array",
301 330
          "items" => @issue_evidence,
lib/openagents_web/controllers/issue_controller.ex modified +12 -1

@@ -12,6 +12,7 @@ defmodule OpenAgentsWeb.IssueController do

12 12
  alias OpenAgents.Agents.Agent
13 13
  alias OpenAgents.PullRequests
14 14
  alias OpenAgents.Repositories
15
  alias OpenAgents.Threads
15 16
  alias OpenAgents.Transparency.WorkDisclosure
16 17
  alias OpenAgentsWeb.ApiError
17 18

@@ -37,6 +38,7 @@ defmodule OpenAgentsWeb.IssueController do

37 38
        work: Assignments.attempts_for_issues(issues, viewer(repository, reader)),
38 39
        evidence: Evidence.for_issues(issues, viewer(repository, reader)),
39 40
        completion_claims: CompletionClaims.for_issues(issues),
41
        threads: threads_by_issue(issues, reader),
40 42
        pagination: %{
41 43
          page: Issues.parse_page(params["page"]),
42 44
          per_page: Issues.per_page(),

@@ -295,7 +297,8 @@ defmodule OpenAgentsWeb.IssueController do

295 297
        pull_requests: PullRequests.markers_by_issue_id([issue]),
296 298
        work: work(issue, repository, reader),
297 299
        evidence: evidence(issue, repository, reader),
298
        completion_claims: completion_claims(issue)
300
        completion_claims: completion_claims(issue),
301
        threads: threads_by_issue([issue], reader)
299 302
      )
300 303
    else
301 304
      {:error, :not_found} -> not_found(conn)

@@ -375,6 +378,14 @@ defmodule OpenAgentsWeb.IssueController do

375 378
376 379
  defp progress(%Issue{} = issue, _reader), do: Issues.progress_map([issue])
377 380
381
  # Threads are the durable work record, and an issue lists the ones that name
382
  # it through the same read authority the thread surface already enforces.
383
  defp threads_by_issue(issues, %OpenAgents.Accounts.User{} = reader) when is_list(issues) do
384
    Map.new(issues, &{&1.id, Threads.list_for_issue(&1, reader)})
385
  end
386
387
  defp threads_by_issue(_issues, _reader), do: %{}
388
378 389
  # The extension namespace is discoverable from the response itself, so a
379 390
  # client never has to infer which OpenAgents fields this deployment sends.
380 391
  defp put_extensions_header(conn),
lib/openagents_web/controllers/issue_json.ex modified +24

@@ -97,11 +97,14 @@ defmodule OpenAgentsWeb.IssueJSON do

97 97
  end
98 98
99 99
  defp put_extension(json, issue, assigns) do
100
    url_base = url_base(assigns)
101
100 102
    extension =
101 103
      %{}
102 104
      |> put_dependencies(Map.get(assigns, :dependencies), issue)
103 105
      |> put_progress(Map.get(assigns, :progress), issue)
104 106
      |> put_work(Map.get(assigns, :work), issue)
107
      |> put_threads(Map.get(assigns, :threads), issue, url_base)
105 108
      |> put_evidence(Map.get(assigns, :evidence), issue)
106 109
      |> put_completion_claims(Map.get(assigns, :completion_claims), issue)
107 110

@@ -131,6 +134,27 @@ defmodule OpenAgentsWeb.IssueJSON do

131 134
    Map.put(extension, :work, attempts |> Map.get(issue.id, []) |> Enum.map(&attempt_json/1))
132 135
  end
133 136
137
  defp put_threads(extension, nil, _issue, _url_base), do: extension
138
139
  defp put_threads(extension, threads, issue, url_base) when is_map(threads) do
140
    Map.put(
141
      extension,
142
      :threads,
143
      threads |> Map.get(issue.id, []) |> Enum.map(&thread_json(&1, url_base))
144
    )
145
  end
146
147
  defp thread_json(thread, url_base) do
148
    %{
149
      id: thread.id,
150
      status: thread.status,
151
      visibility: thread.visibility,
152
      inserted_at: thread.inserted_at,
153
      updated_at: thread.updated_at,
154
      url: "#{url_base}/api/v1/threads/#{thread.id}"
155
    }
156
  end
157
134 158
  defp put_evidence(extension, nil, _issue), do: extension
135 159
136 160
  defp put_evidence(extension, evidence, issue) when is_map(evidence) do
priv/migration_lineages/prior-2026-08-19.json modified +1

@@ -299,6 +299,7 @@

299 299
    20260824231951,
300 300
    20260825024500,
301 301
    20260825054906,
302
    20260825101934,
302 303
    20260825102408
303 304
  ],
304 305
  "required_tables": [
priv/repo/migrations/20260825101934_add_issue_reference_to_threads.exs added +20

@@ -0,0 +1,20 @@

1
defmodule OpenAgents.Repo.Migrations.AddIssueReferenceToThreads do
2
  @moduledoc """
3
  A thread may name the issue it is doing work for.
4
5
  The reference is nullable: a thread can exist without naming an issue,
6
  and an issue is never a second work record. Deleting an issue leaves the
7
  thread behind with a null reference, because the thread is the durable
8
  work record.
9
  """
10
11
  use Ecto.Migration
12
13
  def change do
14
    alter table(:threads) do
15
      add :issue_id, references(:issues, on_delete: :nilify_all)
16
    end
17
18
    create index(:threads, [:issue_id])
19
  end
20
end
test/openagents/threads/grant_token_reach_test.exs modified +1

@@ -61,6 +61,7 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

61 61
    {:latest_grant, 1} => :thread_struct,
62 62
    {:list_events, 1} => :thread_struct,
63 63
    {:list_events, 2} => :thread_struct,
64
    {:list_for_issue, 2} => :list_by_issue,
64 65
    {:list_for_user, 1} => :scoped_by_owner,
65 66
    {:list_for_user, 2} => :scoped_by_owner,
66 67
    {:maximum_event_batch, 0} => :no_thread,
test/openagents/threads/visibility_test.exs modified +44

@@ -17,6 +17,8 @@ defmodule OpenAgents.Threads.VisibilityTest do

17 17
  alias OpenAgents.Threads.Thread
18 18
  alias OpenAgents.Transparency
19 19
20
  import OpenAgents.IssuesFixtures
21
20 22
  defp owner(key), do: github_user("thread-visibility-#{key}")
21 23
22 24
  defp event_types(thread) do

@@ -173,4 +175,46 @@ defmodule OpenAgents.Threads.VisibilityTest do

173 175
      assert [%{"visibility" => "dark"}] = export["threads"]["records"]
174 176
    end
175 177
  end
178
179
  describe "issue references" do
180
    test "a thread can name an issue and remain unnamed" do
181
      user = owner("issue-link")
182
      repository = repository_fixture()
183
      issue = issue_fixture(repository, title: "Linked issue")
184
185
      assert {:ok, named} =
186
               Threads.open(user, "Work for the issue", issue_id: issue.id)
187
188
      assert named.issue_id == issue.id
189
190
      assert {:ok, unnamed} = Threads.open(user, "Work with no issue")
191
      assert is_nil(unnamed.issue_id)
192
    end
193
194
    test "list_for_issue returns only threads the reader may read" do
195
      repository = repository_fixture()
196
      issue = issue_fixture(repository, title: "Issue with threads")
197
      owner = owner("issue-owner")
198
      stranger = owner("issue-stranger")
199
200
      {:ok, dark} =
201
        Threads.open(owner, "Owner-only thread", issue_id: issue.id)
202
203
      {:ok, ledger} =
204
        Threads.open(owner, "Ledger thread",
205
          issue_id: issue.id,
206
          visibility: "ledger"
207
        )
208
209
      owner_threads = Threads.list_for_issue(issue, owner) |> Enum.map(& &1.id)
210
      assert dark.id in owner_threads
211
      assert ledger.id in owner_threads
212
213
      stranger_threads =
214
        Threads.list_for_issue(issue, stranger) |> Enum.map(& &1.id)
215
216
      refute dark.id in stranger_threads
217
      assert ledger.id in stranger_threads
218
    end
219
  end
176 220
end

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