Let a thread report what it did, and resume one that has

8c53d09803df · AtlantisPleb · · parent 16b0b813cd08

Let a thread report what it did, and resume one that has

`OpenAgents.Threads.finish/2` writes a thread's real report with an empty
error code, and it had no HTTP route and no caller in `lib/` — only tests. So
`DELETE /api/v1/threads/{id}` was the only way to end a thread, and it
hard-codes `error_code: "cancelled"` with "The thread was cancelled before it
reported." Sessions that answered correctly and exited 0 were recorded as
cancellations: 31 of one account's 50 most recent threads read that way. The
permanent record said the opposite of what happened (#106).

`POST /api/v1/threads/{id}/report` routes `finish/2`. The mirror of the bug
would be worse, so the outcome is stated rather than inferred: `status` is
required, one of the terminal three, and never defaults to `succeeded` — the
server did not run the turns and cannot know whether they answered anything.
The status and the error code have to agree, `succeeded` carrying none and
`failed` or `cancelled` naming one, refused by `terminal_changeset/2` and
again by `threads_terminal_outcome_check`, so no writer can file a run that
failed, was interrupted, or ran out of steps as a success. Reporting revokes
exactly as cancelling does. A resent identical report is answered, because a
client retrying a timed-out call is not reporting twice; a different second
report is refused `thread_terminal`.

That closes the thread, which was the second half of the problem: every honest
end is terminal and `mint_grant/1` refused every terminal thread, so
`oa coder --resume` was refused before it reached the transcript it exists to
replay, and a client could keep a thread resumable only by never saying what it
did. The mint now reopens a reported thread inside its own transaction — the
report is written to the transcript as `thread.reopened`, the terminal columns
clear, the admission cap is retaken under the same owner lock, the generation
advances — so the thread holding authority is an open thread at every instant
and nothing recorded is lost. A cancelled thread stays refused:
`DELETE` is a disposal, and a caller that used it asked for the thread to be
over.

THREAD-001 amended for both halves, with the ledger pointing at the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
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 446 · 2026-08-26T11:03:21.851741Z
built
11 modules in 53.1 s
deployed
live · 11 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 11 modules on 0 nodes · push→live —

Changed files

  • modified INVARIANTS.md
  • modified docs/taxonomy.md
  • modified lib/openagents/threads.ex
  • modified lib/openagents/threads/thread.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260826103000_add_thread_terminal_outcome_check.exs
  • modified test/openagents/threads_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

12 files changed, +875 -35

INVARIANTS.md modified +40 -3

@@ -2631,8 +2631,45 @@ conversation, and a thread is not one.

2631 2631
- **Authority does not outlive the thread.** Every terminal transition
2632 2632
  (`OpenAgents.Threads.finish/2`, `OpenAgents.Threads.cancel/2`) revokes the
2633 2633
  thread's active grants inside the transaction that writes the terminal row,
2634
  and `mint_grant/1` refuses a thread that is not open. Deleting a thread — or
2635
  the account, under the DATA-004 cascade — deletes its grants with it.
2634
  so no terminal thread ever holds a live grant. Deleting a thread — or the
2635
  account, under the DATA-004 cascade — deletes its grants with it.
2636
2637
  Amended 2026-08-26 (issue #106): a thread that reported can be granted
2638
  again, and being granted again is what reopens it.
2639
  `OpenAgents.Threads.mint_grant/1` used to refuse every thread that was not
2640
  open, which made the two halves of the contract contradict each other the
2641
  moment a client started ending its threads honestly: every honest end is
2642
  terminal, so a session could keep a thread resumable only by never saying
2643
  what it did. `oa coder --resume` was refused before it reached the
2644
  transcript it exists to replay. The mint now reopens a reported thread
2645
  inside its own transaction — the report is written to the transcript as
2646
  `thread.reopened`, the terminal columns clear, the admission cap is taken
2647
  again under the same owner lock, and the generation advances — so the thread
2648
  holding authority is an open thread at every instant, and reopening loses
2649
  nothing that was recorded. A **cancelled** thread stays refused
2650
  `:thread_terminal`: `DELETE` is a disposal, and a caller that used it asked
2651
  for the thread to be over. A local-lane thread stays refused
2652
  `:thread_local_lane` in every state.
2653
- **A terminal thread says what happened, and cannot say two things.** Added
2654
  2026-08-26 (issue #106). `POST /api/v1/threads/{id}/report` routes
2655
  `finish/2`, which had no route and no caller in `lib/` — so `DELETE` was the
2656
  only way to end a thread, and 31 of one account's 50 most recent threads
2657
  read `cancelled` with "The thread was cancelled before it reported." for
2658
  sessions that answered correctly and exited 0. The record said the opposite
2659
  of what happened. The mirror of that bug is worse, so the outcome is stated
2660
  rather than inferred and the statement has to be internally consistent: the
2661
  route requires an explicit `status` from the terminal three and never
2662
  defaults to `succeeded`, and `succeeded` carries no `error_code` while
2663
  `failed` and `cancelled` must each name one.
2664
  `OpenAgents.Threads.Thread.terminal_changeset/2` refuses a pair that
2665
  disagrees and `threads_terminal_outcome_check` refuses it again in the
2666
  database, so a run that failed, was interrupted, or exhausted its steps
2667
  cannot be filed as a success by any writer. Reporting revokes exactly as
2668
  cancelling does. A resent identical report is answered rather than refused,
2669
  because a client retrying a call it never saw the answer to is not reporting
2670
  twice; a *different* second report is refused `thread_terminal`, because a
2671
  thread reports once and the standing report is not overwritten by a later
2672
  claim.
2636 2673
- **A local-lane thread holds no authority, ever.** Added 2026-08-25 (issue
2637 2674
  #243). `threads.lane` admits `thread` and `local` by check constraint,
2638 2675
  defaulting to `thread` — the granted lane every earlier thread came

@@ -6303,7 +6340,7 @@ contract; the invariant prose above defines the assertion, not the filename.

6303 6340
| WORK-001 | `test/openagents/work_job_test.exs`, `test/openagents/deep_work_tool_loop_test.exs` |
6304 6341
| SELF-EDIT-001 | `test/openagents/tools/repository_mutation_tools_test.exs`, `test/openagents/coding_job_test.exs`, `test/openagents/dependency_boundary_test.exs` |
6305 6342
| SCV-001 | `test/openagents/scv/deployments_test.exs`, `test/openagents/dependency_boundary_test.exs` |
6306
| 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`, `test/openagents/threads/local_lane_test.exs` |
6343
| 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`, `test/openagents/threads/local_lane_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs` |
6307 6344
| THREAD-002 | `test/openagents/threads/visibility_test.exs`, `test/openagents_web/thread_visibility_test.exs`, `test/openagents/threads/grant_token_reach_test.exs` |
6308 6345
| THREAD-003 | `test/openagents/threads_test.exs`, `test/openagents/threads/visibility_test.exs` |
6309 6346
| 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` |
docs/taxonomy.md modified +17 -3

@@ -400,9 +400,23 @@ binds to it: an inference grant names a thread or a conversation, never both

400 400
and never neither (THREAD-001). The context is `OpenAgents.Threads`, the record
401 401
is `OpenAgents.Threads.Thread`, and the transcript entry is
402 402
`OpenAgents.Threads.Event`. A caller opens one with `POST /api/v1/threads`,
403
reads what it has spent with `GET /api/v1/threads/{thread_id}`, and revokes it
404
with `DELETE /api/v1/threads/{thread_id}`, all behind the `chat:account` scope
405
and served by `OpenAgentsWeb.ThreadController`. A thread opened with
403
reads what it has spent with `GET /api/v1/threads/{thread_id}`, ends it with
404
`POST /api/v1/threads/{thread_id}/report`, and cancels it with
405
`DELETE /api/v1/threads/{thread_id}`, all behind the `chat:account` scope
406
and served by `OpenAgentsWeb.ThreadController`.
407
408
**Report versus cancel** — the two ways a thread ends, and they say different
409
things. To **report** is to say what the thread did: the caller names the
410
outcome (`succeeded`, `failed`, or `cancelled`) and the sentence that goes with
411
it, and the record carries that. To **cancel** is to end a thread that never
412
reported; `DELETE` writes `cancelled` with the error code `cancelled` and the
413
sentence "The thread was cancelled before it reported." Do not write "cancel"
414
for a session that finished its work, and do not write "finish" or "complete"
415
for a `DELETE` — the whole point of the pair is that a session that answered
416
correctly is not recorded as an abandonment (issue #106). To **resume** is to
417
ask for authority on a thread that already reported: `POST /grants` reopens it,
418
records `thread.reopened` in the transcript, and grants a new generation. A
419
cancelled thread cannot be resumed, because cancelling is a disposal. A thread opened with
406 420
`"lane": "local"` is transcript-only: its model is the vendor string a local
407 421
runtime serves, and it is never granted authority — the server records the run
408 422
without paying for it (THREAD-001, issue #243). The CLI that stops writing to
lib/openagents/threads.ex modified +99 -17

@@ -728,33 +728,50 @@ defmodule OpenAgents.Threads do

728 728
  tokens, or cost, is refused `:parent_authority_exhausted` rather than minted
729 729
  authority it cannot use.
730 730
731
  A local-lane thread is refused `:thread_local_lane` the way a terminal
732
  thread is refused `:thread_terminal`. Its model is a vendor string a local
733
  runtime serves, not an admitted catalog id, so a grant naming it would be
734
  authority no provider here can honor — and the lane's whole contract is that
735
  it holds none (issue #243). The refusal is what keeps the no-provider-key
736
  and metering invariants true by construction rather than by review.
731
  A local-lane thread is refused `:thread_local_lane`. Its model is a vendor
732
  string a local runtime serves, not an admitted catalog id, so a grant naming
733
  it would be authority no provider here can honor — and the lane's whole
734
  contract is that it holds none (issue #243). The refusal is what keeps the
735
  no-provider-key and metering invariants true by construction rather than by
736
  review.
737
738
  This is also the resume door. A thread that reported is reopened here: its
739
  report is written into the transcript as `thread.reopened`, the terminal
740
  columns clear, the account's admission cap is taken again, and the thread is
741
  granted fresh authority under a new generation. Without that, a client that
742
  ends its thread honestly could never come back to it — every honest end is
743
  terminal, and `mint_grant/1` used to refuse every terminal thread — so
744
  `oa coder --resume` would be refused before it could replay anything, and the
745
  only way to keep a thread resumable would be never to say what it did (issue
746
  #106). A cancelled thread is the exception, refused `:thread_terminal`:
747
  `DELETE` is a disposal, and a caller that used it asked for the thread to be
748
  over.
737 749
738 750
  This is the fence. In one transaction: the thread is locked and refused
739
  unless it is open, every active grant naming it is revoked, `generation` is
740
  bumped, and a fresh grant is minted against the thread — never against a
741
  conversation. Returns the plaintext token exactly once.
751
  unless it can hold authority, every active grant naming it is revoked, a
752
  thread that had reported is reopened, `generation` is bumped, and a fresh
753
  grant is minted against the thread — never against a conversation. Returns
754
  the plaintext token exactly once.
742 755
  """
743 756
  @spec mint_grant(Thread.t()) ::
744 757
          {:ok, Thread.t(), Grant.t(), String.t()}
745 758
          | {:error,
746 759
             :thread_terminal
747 760
             | :thread_local_lane
761
             | :thread_quota_reached
748 762
             | :credit_exhausted
749 763
             | :parent_authority_exhausted
750 764
             | Ecto.Changeset.t()}
751 765
  def mint_grant(%Thread{} = thread) do
752 766
    Repo.transaction(fn ->
753 767
      case locked(thread.id) do
754
        %Thread{status: "open", lane: "local"} ->
768
        %Thread{lane: "local"} ->
755 769
          Repo.rollback(:thread_local_lane)
756 770
757
        %Thread{status: "open"} = current ->
771
        %Thread{status: "cancelled"} ->
772
          Repo.rollback(:thread_terminal)
773
774
        %Thread{} = current ->
758 775
          # Concurrent mints for one account serialize on the owner row
759 776
          # (locked after the thread row, always in that order), so each mint
760 777
          # reads the metered remainder at its own turn rather than from a

@@ -763,7 +780,8 @@ defmodule OpenAgents.Threads do

763 780
          _serialized = lock_owner(Repo, current.owner_visitor_id)
764 781
          _revoked = Inference.revoke_active_for_thread(current.id)
765 782
766
          with {:ok, fenced} <- current |> Thread.generation_changeset() |> Repo.update(),
783
          with {:ok, reopened} <- reopen(current),
784
               {:ok, fenced} <- reopened |> Thread.generation_changeset() |> Repo.update(),
767 785
               {:ok, ceilings} <- grant_ceilings(fenced),
768 786
               {:ok, grant, token} <-
769 787
                 Inference.mint(%{

@@ -777,9 +795,6 @@ defmodule OpenAgents.Threads do

777 795
          else
778 796
            {:error, reason} -> Repo.rollback(reason)
779 797
          end
780
781
        _terminal ->
782
          Repo.rollback(:thread_terminal)
783 798
      end
784 799
    end)
785 800
    |> case do

@@ -788,20 +803,81 @@ defmodule OpenAgents.Threads do

788 803
    end
789 804
  end
790 805
806
  # An open thread is already where a mint needs it. A thread that reported is
807
  # reopened first: its report moves into the transcript, the terminal columns
808
  # clear, and the account's admission cap is taken again, because a reopened
809
  # thread holds an open thread's slot and an open thread's grant. Called
810
  # inside `mint_grant/1`'s transaction, after the owner row is locked, so the
811
  # count is the same serialized count `open/3` takes (issue #195).
812
  defp reopen(%Thread{status: "open"} = thread), do: {:ok, thread}
813
814
  defp reopen(%Thread{} = thread) do
815
    ceiling = maximum_open_per_account()
816
817
    if ceiling != nil and open_count(thread.owner_visitor_id) >= ceiling do
818
      {:error, :thread_quota_reached}
819
    else
820
      with {:ok, _event} <-
821
             insert_event(
822
               thread,
823
               "thread.reopened",
824
               reopened_payload(thread),
825
               DateTime.utc_now()
826
             ),
827
           {:ok, counted} <-
828
             thread
829
             |> Thread.event_count_changeset(thread.event_count + 1)
830
             |> Repo.update() do
831
        counted |> Thread.reopen_changeset() |> Repo.update()
832
      end
833
    end
834
  end
835
836
  # Reopening clears the terminal columns, so what the thread reported is
837
  # written into the transcript on the way past. The transcript is the durable
838
  # record either way, and this is what makes reopening lossless: a reader can
839
  # still see that the thread reported, what it said, and when.
840
  defp reopened_payload(%Thread{} = thread) do
841
    %{
842
      "status" => thread.status,
843
      "report" => thread.report,
844
      "report_type" => thread.report_type,
845
      "error_code" => thread.error_code,
846
      "completed_at" => thread.completed_at && DateTime.to_iso8601(thread.completed_at),
847
      "generation" => thread.generation
848
    }
849
  end
850
791 851
  @doc """
792 852
  End a thread with its bounded, typed report, revoking its authority in the
793 853
  same transaction. Idempotent refusal on an already-terminal thread.
854
855
  This is how a thread says what it did. `cancel/2` is how it says it was
856
  disposed of before saying anything, and the two are not interchangeable: a
857
  session that answered and exited 0 recorded as a cancellation says the
858
  opposite of what happened (issue #106). Its mirror is worse, so the outcome
859
  and the error code have to agree — `succeeded` carries no error code, and
860
  `failed` or `cancelled` has to name one. `Thread.terminal_changeset/2`
861
  refuses the pairs that disagree and `threads_terminal_outcome_check` refuses
862
  them again in the database.
863
864
  The status defaults to `succeeded` for an in-process caller that has already
865
  decided the work succeeded. `POST /api/v1/threads/{id}/report` takes no such
866
  default: a client states the outcome or is refused, because the server has no
867
  way to know whether a turn it did not run answered anything.
794 868
  """
795 869
  @spec finish(Thread.t(), map()) ::
796 870
          {:ok, Thread.t()} | {:error, :thread_terminal | Ecto.Changeset.t()}
797 871
  def finish(%Thread{} = thread, result) when is_map(result) do
798 872
    report = Map.get(result, :report) || Map.get(result, "report") || ""
873
    status = Map.get(result, :status) || Map.get(result, "status") || Thread.succeeded()
799 874
800 875
    attributes = %{
801
      status: Map.get(result, :status) || Map.get(result, "status") || "succeeded",
876
      status: status,
802 877
      report: report,
803 878
      report_digest: digest(report),
804
      report_type: Map.get(result, :report_type) || Map.get(result, "report_type") || "outcome",
879
      report_type:
880
        Map.get(result, :report_type) || Map.get(result, "report_type") || report_type(status),
805 881
      usage: Map.get(result, :usage) || Map.get(result, "usage") || %{},
806 882
      error_code: Map.get(result, :error_code) || Map.get(result, "error_code"),
807 883
      completed_at: DateTime.utc_now()

@@ -810,6 +886,12 @@ defmodule OpenAgents.Threads do

810 886
    terminate(thread, attributes)
811 887
  end
812 888
889
  # The report's type follows the outcome it reports unless the caller names
890
  # one, so a failure is not filed under the word a success uses.
891
  defp report_type("failed"), do: "failure"
892
  defp report_type("cancelled"), do: "cancelled"
893
  defp report_type(_succeeded), do: "outcome"
894
813 895
  @doc "Cancel a thread, revoking its authority in the same transaction."
814 896
  @spec cancel(Thread.t(), String.t()) ::
815 897
          {:ok, Thread.t()} | {:error, :thread_terminal | Ecto.Changeset.t()}
lib/openagents/threads/thread.ex modified +80 -1

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

28 28
29 29
  @statuses ~w(open succeeded failed cancelled)
30 30
  @terminal_statuses ~w(succeeded failed cancelled)
31
  @succeeded "succeeded"
32
  @cancelled "cancelled"
31 33
  @permission_profiles ~w(read_only workspace_write)
32 34
  @reasoning_efforts ~w(none minimal low medium high max)
33 35
  @objective_bytes 32_768

@@ -126,6 +128,21 @@ defmodule OpenAgents.Threads.Thread do

126 128
  def open?(%__MODULE__{status: "open"}), do: true
127 129
  def open?(%__MODULE__{}), do: false
128 130
131
  @doc "The status a thread takes when its report names no outcome to disagree with."
132
  def succeeded, do: @succeeded
133
134
  @doc """
135
  Whether `thread` was cancelled — the one end that cannot be reopened.
136
137
  A cancelled thread was disposed of on purpose: `DELETE /api/v1/threads/{id}`
138
  is the verb, and a caller that used it asked for the thread to be over.
139
  Every other end is a state the work reached, and a later session may be
140
  granted authority on it again (THREAD-001).
141
  """
142
  @spec cancelled?(t()) :: boolean()
143
  def cancelled?(%__MODULE__{status: @cancelled}), do: true
144
  def cancelled?(%__MODULE__{}), do: false
145
129 146
  @doc """
130 147
  The immutable capture at open time. `owner_visitor_id`, `status`,
131 148
  `generation`, and `started_at` are set by the context, never cast from a

@@ -199,7 +216,20 @@ defmodule OpenAgents.Threads.Thread do

199 216
    |> check_constraint(:event_count, name: :threads_event_count_nonnegative_check)
200 217
  end
201 218
202
  @doc "The terminal receipt. A thread ends once and carries a typed report when it does."
219
  @doc """
220
  The terminal receipt. A thread ends once and carries a typed report when it
221
  does.
222
223
  The status and the error code have to agree, and the agreement is checked
224
  here rather than at each caller: `succeeded` means the thread carries no
225
  error code, and every other terminal status has to name one. Without that
226
  rule a caller could file an interrupted run, a failed one, or one that ran
227
  out of steps as `succeeded`, and the durable record would read as the
228
  opposite of what happened — which is the same class of bug as recording a
229
  session that answered correctly as `cancelled` (issue #106). The database
230
  refuses the same pair (`threads_terminal_outcome_check`), so no writer that
231
  skips this changeset can file one either.
232
  """
203 233
  def terminal_changeset(%__MODULE__{} = thread, attributes) do
204 234
    thread
205 235
    |> cast(attributes, [

@@ -217,9 +247,58 @@ defmodule OpenAgents.Threads.Thread do

217 247
    |> validate_format(:report_digest, ~r/\Asha256:[0-9a-f]{64}\z/)
218 248
    |> validate_length(:report_type, max: 80)
219 249
    |> validate_length(:error_code, max: 80)
250
    |> validate_outcome()
220 251
    |> check_constraint(:status, name: :threads_status_check)
221 252
    |> check_constraint(:report, name: :threads_report_bound_check)
222 253
    |> check_constraint(:report_type, name: :threads_report_type_bound_check)
254
    |> check_constraint(:error_code, name: :threads_terminal_outcome_check)
255
    |> check_constraint(:completed_at, name: :threads_terminal_shape_check)
256
  end
257
258
  defp validate_outcome(changeset) do
259
    status = get_field(changeset, :status)
260
    error_code = get_field(changeset, :error_code)
261
262
    cond do
263
      status == @succeeded and present?(error_code) ->
264
        add_error(
265
          changeset,
266
          :error_code,
267
          "must be empty on a thread that succeeded; name the status the outcome actually had"
268
        )
269
270
      status in @terminal_statuses and status != @succeeded and not present?(error_code) ->
271
        add_error(changeset, :error_code, "is required on a thread that did not succeed")
272
273
      true ->
274
        changeset
275
    end
276
  end
277
278
  defp present?(value), do: is_binary(value) and String.trim(value) != ""
279
280
  @doc """
281
  Reopen a thread that ended, so a later session can be granted authority on it
282
  again.
283
284
  A thread that reported is the thing `oa coder --resume` comes back to, and a
285
  grant cannot be minted for a terminal thread (THREAD-001). Reopening clears
286
  the terminal columns, which the shape constraint requires of an open row, and
287
  `OpenAgents.Threads.mint_grant/1` writes the report it is clearing into the
288
  transcript first, so nothing the thread reported is lost. Cancelling is the
289
  one end this does not undo: it is a disposal, not a pause.
290
  """
291
  def reopen_changeset(%__MODULE__{} = thread) do
292
    thread
293
    |> change(%{
294
      status: "open",
295
      report: nil,
296
      report_digest: nil,
297
      report_type: nil,
298
      error_code: nil,
299
      completed_at: nil
300
    })
301
    |> check_constraint(:status, name: :threads_status_check)
223 302
    |> check_constraint(:completed_at, name: :threads_terminal_shape_check)
224 303
  end
225 304
end
lib/openagents_web/api_route_authority.ex modified +1

@@ -205,6 +205,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

205 205
      "get /api/v1/threads" => {:required_bearer, :thread, :envelope},
206 206
      "get /api/v1/threads/:thread_id" => {:required_bearer, :thread, :envelope},
207 207
      "delete /api/v1/threads/:thread_id" => {:required_bearer, :thread, :envelope},
208
      "post /api/v1/threads/:thread_id/report" => {:required_bearer, :thread, :envelope},
208 209
      "get /api/v1/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
209 210
      "post /api/v1/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
210 211
      "post /api/v1/threads/:thread_id/grants" => {:required_bearer, :thread, :envelope},
lib/openagents_web/controllers/api_extension_controller.ex modified +27

@@ -456,6 +456,10 @@ defmodule OpenAgentsWeb.ApiExtensionController do

456 456
        "GET /api/v1/models",
457 457
        "POST /api/v1/threads",
458 458
        "GET /api/v1/threads/{thread_id}",
459
        "GET /api/v1/threads/{thread_id}/events",
460
        "POST /api/v1/threads/{thread_id}/events",
461
        "POST /api/v1/threads/{thread_id}/report",
462
        "POST /api/v1/threads/{thread_id}/grants",
459 463
        "DELETE /api/v1/threads/{thread_id}"
460 464
      ],
461 465
      "parameters" => %{

@@ -550,6 +554,29 @@ defmodule OpenAgentsWeb.ApiExtensionController do

550 554
            "`credit_exhausted`. Authority that passes `expires_at` stops " <>
551 555
            "being live and stops holding a slot, with or without a request."
552 556
      },
557
      "ending" => %{
558
        "description" =>
559
          "A thread ends one of two ways, and they are not interchangeable. " <>
560
            "POST /api/v1/threads/{thread_id}/report says what the thread did " <>
561
            "and revokes its authority; DELETE /api/v1/threads/{thread_id} " <>
562
            "cancels a thread that never reported and revokes the same way. A " <>
563
            "session that answered and exited 0 must report, or its permanent " <>
564
            "record reads as a cancellation. The report body names `status` — " <>
565
            "required, one of the terminal statuses, never assumed — and " <>
566
            "`report`, with optional `report_type` and `usage`. `status` and " <>
567
            "`error_code` must agree: `succeeded` carries no error code, and " <>
568
            "`failed` or `cancelled` must name one, so a run that failed " <>
569
            "cannot be filed as a success. Resending an identical report is " <>
570
            "answered; a different second report is refused `thread_terminal`.",
571
        "statuses" => OpenAgents.Threads.Thread.terminal_statuses(),
572
        "resume" =>
573
          "A thread that reported is not finished with. POST " <>
574
            "/api/v1/threads/{thread_id}/grants reopens it, records what it " <>
575
            "reported in the transcript as `thread.reopened`, and returns " <>
576
            "fresh authority under a new generation, so a later session can " <>
577
            "replay the transcript and carry on. A cancelled thread is refused " <>
578
            "`thread_terminal`: cancelling is a disposal, not a pause."
579
      },
553 580
      "grant" => %{
554 581
        "description" =>
555 582
          "POST returns the plaintext token exactly once. Spend it as the " <>
lib/openagents_web/controllers/thread_controller.ex modified +211 -8

@@ -1,6 +1,7 @@

1 1
defmodule OpenAgentsWeb.ThreadController do
2 2
  @moduledoc """
3
  The door to a thread: open one, read what it has spent, revoke it.
3
  The door to a thread: open one, read what it has spent, say what it did,
4
  revoke it.
4 5
5 6
  A thread is the unit of agent work (`docs/taxonomy.md`), and its grant is the
6 7
  only way a client reaches a model without holding a provider key. So these

@@ -17,6 +18,12 @@ defmodule OpenAgentsWeb.ThreadController do

17 18
  - **Revocation does not wait to be asked.** `DELETE` revokes immediately, and
18 19
    every request first retires the account's elapsed authority, so a grant
19 20
    past its expiry stops being live whether or not anyone presents it.
21
  - **Ending honestly is a different act from being cancelled.**
22
    `POST /report` writes what the thread did and revokes; `DELETE` writes a
23
    cancellation and revokes. A session that answered and exited 0 has to be
24
    able to say so, or its permanent record says the opposite of what happened
25
    (issue #106) — and a session that failed has to be unable to claim it
26
    succeeded, which is the same bug pointed the other way.
20 27
  - **Disclosure is opt-in and narrow.** A thread opens `dark` — owner-only —
21 28
    unless the caller names a wider transparency tier, and a tier this surface
22 29
    cannot enforce is refused with `thread_visibility_unsupported`. A wider

@@ -168,6 +175,82 @@ defmodule OpenAgentsWeb.ThreadController do

168 175
    end)
169 176
  end
170 177
178
  @doc """
179
  Say what the thread did, and end it.
180
181
  This is the route a session calls when its work is over and it has something
182
  to say about it. Without it the only way to end a thread was `DELETE`, which
183
  writes `cancelled` and the sentence "The thread was cancelled before it
184
  reported." — so a session that answered correctly and exited 0 was recorded
185
  as a cancellation, and 31 of one account's 50 most recent threads read that
186
  way (issue #106). The record said the opposite of what happened.
187
188
  The outcome is the caller's to state, and stating it is mandatory. A body
189
  with no `status` is refused rather than filed as a success: the server did
190
  not run the turns and has no way to know whether they answered anything, and
191
  a default of `succeeded` would be the same bug pointed the other way — a run
192
  that failed, was interrupted, or exhausted its steps recorded as having
193
  worked. The status and the error code have to agree: `succeeded` carries no
194
  error code, and `failed` or `cancelled` has to name one. Both halves are
195
  refused by `OpenAgents.Threads.Thread.terminal_changeset/2` and by
196
  `threads_terminal_outcome_check`, so no client and no future caller can file
197
  a pair that disagrees.
198
199
  Ending revokes, exactly as `DELETE` does — authority does not outlive the
200
  thread (THREAD-001) — and the response carries the revoked grant so a client
201
  reads what the session spent in the same answer that ends it.
202
203
  A resent identical report is answered rather than refused, so a client that
204
  retries a timed-out call is not told its own report failed. A *different*
205
  second report is refused `thread_terminal`: a thread reports once, and the
206
  standing report is not overwritten by a later claim.
207
208
  A thread that reported is not finished with. `POST /grants` reopens it and
209
  hands back fresh authority, which is what `oa coder --resume` needs; see
210
  `mint/2`.
211
  """
212
  def report(conn, %{"thread_id" => thread_id} = params) do
213
    with_thread(conn, thread_id, fn thread ->
214
      case outcome(params) do
215
        {:ok, result} -> file_report(conn, thread, result)
216
        {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
217
      end
218
    end)
219
  end
220
221
  defp file_report(conn, thread, result) do
222
    case Threads.finish(thread, result) do
223
      {:ok, finished} ->
224
        render_thread(conn, :ok, finished)
225
226
      {:error, :thread_terminal} ->
227
        replay_or_refuse(conn, thread, result)
228
229
      {:error, %Ecto.Changeset{} = changeset} ->
230
        ApiError.changeset(conn, changeset)
231
    end
232
  end
233
234
  # A client that retried a call it never saw the answer to is not reporting
235
  # twice; it is asking whether its one report landed. So an identical resend
236
  # is answered with the standing thread, and only a report that says something
237
  # different is refused.
238
  defp replay_or_refuse(conn, thread, result) do
239
    if thread.report == Map.fetch!(result, :report) and
240
         thread.status == Map.fetch!(result, :status) do
241
      render_thread(conn, :ok, thread)
242
    else
243
      sentence =
244
        "This thread is #{thread.status} and has already reported. " <>
245
          "A thread reports once; open another thread instead."
246
247
      ApiError.refuse(conn, "thread_terminal",
248
        message: sentence,
249
        errors: %{"thread" => [sentence]}
250
      )
251
    end
252
  end
253
171 254
  def delete(conn, %{"thread_id" => thread_id}) do
172 255
    with_thread(conn, thread_id, fn thread ->
173 256
      # Cancelling revokes the thread's authority inside the transaction that

@@ -181,13 +264,29 @@ defmodule OpenAgentsWeb.ThreadController do

181 264
    end)
182 265
  end
183 266
267
  @doc """
268
  Re-mint a thread's authority: the resume door.
269
270
  Re-minting is the resume fence — it revokes every active grant, bumps the
271
  generation, and hands back fresh authority on the same thread, so a resumed
272
  session can never race a zombie of its former self (THREAD-001). The
273
  plaintext token exists exactly once, in this response, like the one
274
  `POST /api/v1/threads` returns.
275
276
  A thread that reported is reopened here rather than refused. Every honest end
277
  is terminal, so refusing every terminal thread meant a client could keep a
278
  thread resumable only by never saying what it did — the transcript would be
279
  there and no session could be granted authority to continue it. What it
280
  reported is written into the transcript as `thread.reopened` before the
281
  terminal columns clear, so reopening loses nothing.
282
283
  A cancelled thread is refused `thread_terminal`. `DELETE` is a disposal, and
284
  a caller that used it asked for the thread to be over; resuming it would make
285
  cancellation mean nothing. A local-lane thread is refused `thread_lane_local`
286
  in every state: it can never hold authority at all.
287
  """
184 288
  def mint(conn, %{"thread_id" => thread_id}) do
185 289
    with_thread(conn, thread_id, fn thread ->
186
      # Re-minting is the resume fence: it revokes every active grant, bumps
187
      # the generation, and hands back fresh authority on the same thread, so
188
      # a resumed session can never race a zombie of its former self
189
      # (THREAD-001). The plaintext token exists exactly once, in this
190
      # response, like the one `POST /api/v1/threads` returns.
191 290
      case Threads.mint_grant(thread) do
192 291
        {:ok, minted, grant, token} ->
193 292
          conn

@@ -197,8 +296,9 @@ defmodule OpenAgentsWeb.ThreadController do

197 296
198 297
        {:error, :thread_terminal} ->
199 298
          sentence =
200
            "This thread is #{thread.status} and holds no authority to re-mint. " <>
201
              "Open another thread instead."
299
            "This thread was cancelled, so it holds no authority to re-mint and " <>
300
              "cannot be resumed. Open another thread instead. A thread that " <>
301
              "reported its outcome can be resumed here; a cancelled one is over."
202 302
203 303
          ApiError.refuse(conn, "thread_terminal",
204 304
            message: sentence,

@@ -217,6 +317,9 @@ defmodule OpenAgentsWeb.ThreadController do

217 317
            errors: %{"thread" => [sentence]}
218 318
          )
219 319
320
        {:error, :thread_quota_reached} ->
321
          quota_reached(conn)
322
220 323
        {:error, :credit_exhausted} ->
221 324
          credit_exhausted(conn)
222 325

@@ -523,6 +626,105 @@ defmodule OpenAgentsWeb.ThreadController do

523 626
    {:refused, "events[#{index}]", "#{inspect(event)} is not an object."}
524 627
  end
525 628
629
  # What a thread reports, read from the body with nothing inferred. Every
630
  # refusal here is a 422 naming its field: the alternative is guessing, and a
631
  # guess that lands on `succeeded` is the mirror of the bug this route exists
632
  # to fix (issue #106).
633
  defp outcome(params) do
634
    with {:ok, status} <- terminal_status(params),
635
         {:ok, report} <- terminal_report(params),
636
         {:ok, error_code} <- error_code(params, status),
637
         {:ok, report_type} <- report_type(params),
638
         {:ok, usage} <- usage(params) do
639
      {:ok,
640
       %{
641
         status: status,
642
         report: report,
643
         error_code: error_code,
644
         usage: usage
645
       }
646
       |> put_present(:report_type, report_type)}
647
    end
648
  end
649
650
  defp put_present(map, _key, nil), do: map
651
  defp put_present(map, key, value), do: Map.put(map, key, value)
652
653
  defp terminal_status(%{"status" => status}) when is_binary(status) do
654
    if status in Thread.terminal_statuses() do
655
      {:ok, status}
656
    else
657
      {:refused, "status",
658
       "must be one of #{Enum.join(Thread.terminal_statuses(), ", ")}, naming how the thread ended"}
659
    end
660
  end
661
662
  defp terminal_status(_params) do
663
    {:refused, "status",
664
     "is required: name how the thread ended, one of " <>
665
       "#{Enum.join(Thread.terminal_statuses(), ", ")}. The server does not assume a run " <>
666
       "succeeded because it stopped."}
667
  end
668
669
  defp terminal_report(%{"report" => report}) when is_binary(report) do
670
    case String.trim(report) do
671
      "" -> {:refused, "report", "cannot be blank"}
672
      _present -> {:ok, report}
673
    end
674
  end
675
676
  defp terminal_report(_params), do: {:refused, "report", "is required"}
677
678
  # A success that names an error code and a failure that names none are both
679
  # refused, because the durable record has to say one thing about what
680
  # happened rather than two.
681
  defp error_code(params, status) do
682
    given = params |> Map.get("error_code") |> blank_to_nil()
683
684
    cond do
685
      not is_nil(Map.get(params, "error_code")) and not is_binary(Map.get(params, "error_code")) ->
686
        {:refused, "error_code", "must be a string"}
687
688
      status == Thread.succeeded() and given != nil ->
689
        {:refused, "error_code",
690
         "must be empty on a thread that succeeded; report the status the run actually had"}
691
692
      status != Thread.succeeded() and given == nil ->
693
        {:refused, "error_code", "is required on a thread that did not succeed: name why"}
694
695
      true ->
696
        {:ok, given}
697
    end
698
  end
699
700
  defp report_type(%{"report_type" => report_type}) when is_binary(report_type) do
701
    case String.trim(report_type) do
702
      "" -> {:refused, "report_type", "cannot be blank"}
703
      trimmed when byte_size(trimmed) > 80 -> {:refused, "report_type", "is longer than 80 bytes"}
704
      _present -> {:ok, report_type}
705
    end
706
  end
707
708
  defp report_type(%{"report_type" => value}) when not is_nil(value) do
709
    {:refused, "report_type", "must be a string"}
710
  end
711
712
  defp report_type(_params), do: {:ok, nil}
713
714
  defp usage(%{"usage" => usage}) when is_map(usage), do: {:ok, usage}
715
  defp usage(%{"usage" => nil}), do: {:ok, %{}}
716
  defp usage(%{"usage" => _other}), do: {:refused, "usage", "must be an object"}
717
  defp usage(_params), do: {:ok, %{}}
718
719
  defp blank_to_nil(value) when is_binary(value) do
720
    case String.trim(value) do
721
      "" -> nil
722
      _present -> value
723
    end
724
  end
725
726
  defp blank_to_nil(_value), do: nil
727
526 728
  defp event_type(%{"event_type" => event_type}) when is_binary(event_type) do
527 729
    if String.trim(event_type) == "" do
528 730
      {:refused, "event_type", "The event type names what happened and cannot be blank."}

@@ -780,6 +982,7 @@ defmodule OpenAgentsWeb.ThreadController do

780 982
      "generation" => thread.generation,
781 983
      "event_count" => thread.event_count,
782 984
      "report" => thread.report,
985
      "report_type" => thread.report_type,
783 986
      "error_code" => thread.error_code,
784 987
      "started_at" => stamp(thread.started_at),
785 988
      "completed_at" => stamp(thread.completed_at),
lib/openagents_web/router.ex modified +1

@@ -647,6 +647,7 @@ defmodule OpenAgentsWeb.Router do

647 647
    get "/threads", ThreadController, :index
648 648
    get "/threads/:thread_id", ThreadController, :show
649 649
    delete "/threads/:thread_id", ThreadController, :delete
650
    post "/threads/:thread_id/report", ThreadController, :report
650 651
    get "/threads/:thread_id/events", ThreadController, :events
651 652
    post "/threads/:thread_id/events", ThreadController, :record
652 653
    post "/threads/:thread_id/grants", ThreadController, :mint
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -312,7 +312,8 @@

312 312
    20260825195623,
313 313
    20260825220000,
314 314
    20260825230000,
315
    20260826010000
315
    20260826010000,
316
    20260826103000
316 317
  ],
317 318
  "required_tables": [
318 319
    "users",
priv/repo/migrations/20260826103000_add_thread_terminal_outcome_check.exs added +40

@@ -0,0 +1,40 @@

1
defmodule OpenAgents.Repo.Migrations.AddThreadTerminalOutcomeCheck do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  A thread's terminal status and its error code have to agree.
6
7
  `succeeded` means no error code; every other terminal status names one. The
8
  rule exists because the terminal row is the durable record of what a session
9
  did, and the two ways it can lie are symmetric: a session that answered and
10
  exited 0 recorded as `cancelled` (issue #106), and a session that failed, was
11
  interrupted, or ran out of steps recorded as `succeeded`. Giving
12
  `OpenAgents.Threads.finish/2` an HTTP route makes the second one reachable by
13
  any client, so the pair is refused here as well as in the changeset.
14
15
  The constraint is created `NOT VALID`: it is enforced on every insert and
16
  update from this point on, and the historical scan is skipped so the
17
  migration does not hold a lock over the whole table while the previous
18
  release is still serving. Every writer that has ever ended a thread —
19
  `cancel/2`, the authority reaper, and `finish/2`'s default — already writes a
20
  pair this admits, so there is nothing for the scan to find; skipping it is a
21
  deployment courtesy rather than a concession.
22
  """
23
24
  def up do
25
    execute("""
26
    ALTER TABLE threads
27
    ADD CONSTRAINT threads_terminal_outcome_check
28
    CHECK (
29
      status = 'open'
30
      OR (status = 'succeeded' AND (error_code IS NULL OR btrim(error_code) = ''))
31
      OR (status <> 'succeeded' AND error_code IS NOT NULL AND btrim(error_code) <> '')
32
    )
33
    NOT VALID
34
    """)
35
  end
36
37
  def down do
38
    execute("ALTER TABLE threads DROP CONSTRAINT threads_terminal_outcome_check")
39
  end
40
end
test/openagents/threads_test.exs modified +121 -1

@@ -141,6 +141,67 @@ defmodule OpenAgents.ThreadsTest do

141 141
      assert cancelled.status == "cancelled"
142 142
      assert cancelled.error_code == "cancelled"
143 143
    end
144
145
    test "a report that names an outcome carries it, error code and all" do
146
      user = owner("finish-failed")
147
      {:ok, thread} = Threads.open(user, "Run out of steps")
148
149
      assert {:ok, failed} =
150
               Threads.finish(thread, %{
151
                 status: "failed",
152
                 report: "The turn budget ran out before an answer.",
153
                 report_type: "failure",
154
                 error_code: "max_steps"
155
               })
156
157
      assert failed.status == "failed"
158
      assert failed.error_code == "max_steps"
159
    end
160
161
    test "a success cannot carry an error code" do
162
      user = owner("finish-incoherent-success")
163
      {:ok, thread} = Threads.open(user, "Claim both")
164
165
      assert {:error, %Ecto.Changeset{} = changeset} =
166
               Threads.finish(thread, %{
167
                 status: "succeeded",
168
                 report: "It worked.",
169
                 error_code: "max_steps"
170
               })
171
172
      assert %{error_code: [_ | _]} = errors_on(changeset)
173
      assert Threads.get_for_user(user, thread.id).status == "open"
174
    end
175
176
    test "a failure has to name why, so nothing ends unexplained" do
177
      user = owner("finish-incoherent-failure")
178
      {:ok, thread} = Threads.open(user, "Fail silently")
179
180
      assert {:error, %Ecto.Changeset{} = changeset} =
181
               Threads.finish(thread, %{status: "failed", report: "It did not work."})
182
183
      assert %{error_code: [_ | _]} = errors_on(changeset)
184
      assert Threads.get_for_user(user, thread.id).status == "open"
185
    end
186
187
    test "the coherence rule is the database's too, not only the changeset's" do
188
      user = owner("finish-coherence-db")
189
      {:ok, thread} = Threads.open(user, "Write around the changeset")
190
191
      assert_raise Postgrex.Error, fn ->
192
        Repo.update_all(
193
          from(t in Thread, where: t.id == ^thread.id),
194
          set: [
195
            status: "succeeded",
196
            report: "It worked.",
197
            report_digest: "sha256:" <> String.duplicate("0", 64),
198
            report_type: "outcome",
199
            error_code: "max_steps",
200
            completed_at: DateTime.utc_now()
201
          ]
202
        )
203
      end
204
    end
144 205
  end
145 206
146 207
  describe "mint_grant/1 — the thread fence" do

@@ -183,7 +244,66 @@ defmodule OpenAgents.ThreadsTest do

183 244
184 245
      assert Threads.active_grants(finished) == []
185 246
      assert {:error, :grant_revoked} = Inference.resolve(token)
186
      assert {:error, :thread_terminal} = Threads.mint_grant(finished)
247
    end
248
249
    test "a cancelled thread is refused: cancelling is the end that means it" do
250
      user = owner("cancelled-no-remint")
251
      {:ok, thread} = Threads.open(user, "Cancel then resume")
252
      {:ok, thread, _grant, _token} = Threads.mint_grant(thread)
253
      {:ok, cancelled} = Threads.cancel(thread)
254
255
      assert {:error, :thread_terminal} = Threads.mint_grant(cancelled)
256
      assert Threads.get_for_user(user, thread.id).status == "cancelled"
257
    end
258
259
    test "a thread that reported is re-granted by reopening it, and keeps its report" do
260
      user = owner("remint-reported")
261
      {:ok, thread} = Threads.open(user, "Report then resume")
262
      {:ok, thread, _grant, first_token} = Threads.mint_grant(thread)
263
      {:ok, finished} = Threads.finish(thread, %{report: "It worked.", report_type: "outcome"})
264
265
      assert {:ok, resumed, grant, token} = Threads.mint_grant(finished)
266
267
      assert resumed.status == "open"
268
      assert resumed.report == nil
269
      assert resumed.report_digest == nil
270
      assert resumed.report_type == nil
271
      assert resumed.error_code == nil
272
      assert resumed.completed_at == nil
273
      assert resumed.generation == 2
274
275
      assert grant.thread_id == thread.id
276
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(token)
277
      assert {:error, :grant_revoked} = Inference.resolve(first_token)
278
279
      # Nothing is lost by reopening: the report the thread carried moves into
280
      # the transcript, which is the durable record either way.
281
      reopened =
282
        resumed |> Threads.list_events() |> Enum.find(&(&1.event_type == "thread.reopened"))
283
284
      assert reopened.payload["status"] == "succeeded"
285
      assert reopened.payload["report"] == "It worked."
286
      assert reopened.payload["error_code"] == nil
287
288
      # And the transcript accepts the resumed session's turns.
289
      assert {:ok, _appended} = Threads.record_event(resumed, "turn.user", %{"text" => "again"})
290
    end
291
292
    test "a failed thread resumes too: failing is a state of the work, not a disposal" do
293
      user = owner("remint-failed")
294
      {:ok, thread} = Threads.open(user, "Fail then resume")
295
      {:ok, thread, _grant, _token} = Threads.mint_grant(thread)
296
297
      {:ok, failed} =
298
        Threads.finish(thread, %{
299
          status: "failed",
300
          report: "The turn budget ran out.",
301
          error_code: "max_steps"
302
        })
303
304
      assert {:ok, resumed, _grant, _token} = Threads.mint_grant(failed)
305
      assert resumed.status == "open"
306
      assert resumed.error_code == nil
187 307
    end
188 308
189 309
    test "deleting a thread deletes its authority" do
test/openagents_web/controllers/thread_controller_test.exs modified +236 -1

@@ -534,6 +534,196 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

534 534
    end
535 535
  end
536 536
537
  describe "POST /api/v1/threads/:thread_id/report" do
538
    setup %{conn: conn} do
539
      authenticated = put_chat_api_token(conn, "thread-report")
540
541
      created =
542
        authenticated
543
        |> post(~p"/api/v1/threads", %{"objective" => "Answer, then say so."})
544
        |> json_response(201)
545
546
      %{
547
        authenticated: authenticated,
548
        id: created["thread"]["id"],
549
        token: created["grant"]["token"]
550
      }
551
    end
552
553
    test "a thread that reported is recorded as having reported, not cancelled", %{
554
      authenticated: conn,
555
      id: id,
556
      token: token
557
    } do
558
      body =
559
        conn
560
        |> post(~p"/api/v1/threads/#{id}/report", %{
561
          "status" => "succeeded",
562
          "report" => "The answer is 4."
563
        })
564
        |> json_response(200)
565
566
      assert body["thread"]["status"] == "succeeded"
567
      assert body["thread"]["error_code"] == nil
568
      assert body["thread"]["report"] == "The answer is 4."
569
      assert body["thread"]["report_type"] == "outcome"
570
571
      # Reporting revokes, exactly as cancelling does: authority does not
572
      # outlive the thread's end (THREAD-001).
573
      assert {:error, :grant_revoked} = Inference.resolve(token)
574
      assert body["grant"]["status"] == "revoked"
575
    end
576
577
    test "the server never guesses the outcome: a report with no status is refused", %{
578
      authenticated: conn,
579
      id: id
580
    } do
581
      body =
582
        conn
583
        |> post(~p"/api/v1/threads/#{id}/report", %{"report" => "Something happened."})
584
        |> json_response(422)
585
586
      assert body["code"] == "validation_failed"
587
      assert body["errors"]["status"] != nil
588
589
      assert conn
590
             |> get(~p"/api/v1/threads/#{id}")
591
             |> json_response(200)
592
             |> get_in([
593
               "thread",
594
               "status"
595
             ]) == "open"
596
    end
597
598
    test "a run that failed cannot be recorded as a success", %{authenticated: conn, id: id} do
599
      body =
600
        conn
601
        |> post(~p"/api/v1/threads/#{id}/report", %{
602
          "status" => "succeeded",
603
          "report" => "It worked.",
604
          "error_code" => "max_steps"
605
        })
606
        |> json_response(422)
607
608
      assert body["code"] == "validation_failed"
609
      assert body["errors"]["error_code"] != nil
610
    end
611
612
    test "a failure has to name why", %{authenticated: conn, id: id} do
613
      body =
614
        conn
615
        |> post(~p"/api/v1/threads/#{id}/report", %{
616
          "status" => "failed",
617
          "report" => "It did not work."
618
        })
619
        |> json_response(422)
620
621
      assert body["code"] == "validation_failed"
622
      assert body["errors"]["error_code"] != nil
623
    end
624
625
    test "a failed run is recorded as failed, with its reason", %{authenticated: conn, id: id} do
626
      body =
627
        conn
628
        |> post(~p"/api/v1/threads/#{id}/report", %{
629
          "status" => "failed",
630
          "report" => "The turn budget ran out before an answer.",
631
          "error_code" => "max_steps"
632
        })
633
        |> json_response(200)
634
635
      assert body["thread"]["status"] == "failed"
636
      assert body["thread"]["error_code"] == "max_steps"
637
      assert body["thread"]["report_type"] == "failure"
638
    end
639
640
    test "an interrupted run reports as cancelled, naming the interruption", %{
641
      authenticated: conn,
642
      id: id
643
    } do
644
      body =
645
        conn
646
        |> post(~p"/api/v1/threads/#{id}/report", %{
647
          "status" => "cancelled",
648
          "report" => "The operator interrupted the session.",
649
          "error_code" => "interrupted"
650
        })
651
        |> json_response(200)
652
653
      assert body["thread"]["status"] == "cancelled"
654
      assert body["thread"]["error_code"] == "interrupted"
655
    end
656
657
    test "a status outside the terminal three is refused", %{authenticated: conn, id: id} do
658
      body =
659
        conn
660
        |> post(~p"/api/v1/threads/#{id}/report", %{
661
          "status" => "open",
662
          "report" => "Still going."
663
        })
664
        |> json_response(422)
665
666
      assert body["errors"]["status"] != nil
667
    end
668
669
    test "a blank report is refused", %{authenticated: conn, id: id} do
670
      body =
671
        conn
672
        |> post(~p"/api/v1/threads/#{id}/report", %{"status" => "succeeded", "report" => "   "})
673
        |> json_response(422)
674
675
      assert body["errors"]["report"] != nil
676
    end
677
678
    test "an at-least-once client may resend the same report", %{authenticated: conn, id: id} do
679
      report = %{"status" => "succeeded", "report" => "The answer is 4."}
680
681
      assert conn |> post(~p"/api/v1/threads/#{id}/report", report) |> json_response(200)
682
      body = conn |> post(~p"/api/v1/threads/#{id}/report", report) |> json_response(200)
683
684
      assert body["thread"]["status"] == "succeeded"
685
    end
686
687
    test "a second, different report is refused rather than overwriting the first", %{
688
      authenticated: conn,
689
      id: id
690
    } do
691
      assert conn
692
             |> post(~p"/api/v1/threads/#{id}/report", %{
693
               "status" => "succeeded",
694
               "report" => "The answer is 4."
695
             })
696
             |> json_response(200)
697
698
      body =
699
        conn
700
        |> post(~p"/api/v1/threads/#{id}/report", %{
701
          "status" => "failed",
702
          "report" => "Actually it broke.",
703
          "error_code" => "max_steps"
704
        })
705
        |> json_response(422)
706
707
      assert body["code"] == "thread_terminal"
708
709
      standing = conn |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
710
      assert standing["thread"]["status"] == "succeeded"
711
    end
712
713
    test "another account cannot report on a thread it did not open", %{conn: conn, id: id} do
714
      body =
715
        conn
716
        |> put_chat_api_token("thread-report-stranger")
717
        |> post(~p"/api/v1/threads/#{id}/report", %{
718
          "status" => "succeeded",
719
          "report" => "Not mine."
720
        })
721
        |> json_response(404)
722
723
      assert body["code"] == "not_found"
724
    end
725
  end
726
537 727
  describe "spending a thread's grant" do
538 728
    test "the grant reaches the model exactly as a conversation-fenced one does", %{conn: conn} do
539 729
      authenticated = put_chat_api_token(conn, "thread-spend")

@@ -996,7 +1186,52 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

996 1186
      assert {:error, :grant_revoked} = Inference.resolve(old_token)
997 1187
    end
998 1188
999
    test "a terminal thread refuses with thread_terminal", %{conn: conn} do
1189
    test "a thread that reported can be resumed: the re-grant reopens it", %{conn: conn} do
1190
      authenticated = put_chat_api_token(conn, "thread-remint-reported")
1191
1192
      created =
1193
        authenticated
1194
        |> post(~p"/api/v1/threads", %{"objective" => "Report, exit, come back."})
1195
        |> json_response(201)
1196
1197
      id = created["thread"]["id"]
1198
1199
      assert authenticated
1200
             |> post(~p"/api/v1/threads/#{id}/report", %{
1201
               "status" => "succeeded",
1202
               "report" => "The answer is 4."
1203
             })
1204
             |> json_response(200)
1205
1206
      body = authenticated |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(201)
1207
1208
      assert body["thread"]["status"] == "open"
1209
      assert body["thread"]["error_code"] == nil
1210
      assert body["thread"]["generation"] == 2
1211
      assert {:ok, _usable} = Inference.resolve(body["grant"]["token"])
1212
1213
      # What the thread reported before is in the transcript, so resuming
1214
      # loses nothing.
1215
      events =
1216
        authenticated
1217
        |> get(~p"/api/v1/threads/#{id}/events")
1218
        |> json_response(200)
1219
        |> Map.fetch!("events")
1220
1221
      reopened = Enum.find(events, &(&1["event_type"] == "thread.reopened"))
1222
      assert reopened["payload"]["report"] == "The answer is 4."
1223
      assert reopened["payload"]["status"] == "succeeded"
1224
1225
      # And the resumed session can append its turns again.
1226
      assert authenticated
1227
             |> post(~p"/api/v1/threads/#{id}/events", %{
1228
               "event_type" => "turn.user",
1229
               "payload" => %{"text" => "and again?"}
1230
             })
1231
             |> json_response(201)
1232
    end
1233
1234
    test "a cancelled thread refuses with thread_terminal", %{conn: conn} do
1000 1235
      authenticated = put_chat_api_token(conn, "thread-remint-terminal")
1001 1236
1002 1237
      created =

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