Show a running attempt live and stop narrating it in prose

9b7e43b37ba6 · AtlantisPleb · · parent 7d21158a1d11

Show a running attempt live and stop narrating it in prose

An issue that had been worked said everything twice. `Assignments`'
`report_claim/1`, `report/1`, and `report_release/1` wrote the claim, the
result, and the release as Markdown comments, and stage 1 already derived the
same events from `forge_assignments`. The page also did not move while an
attempt ran: `IssueShowLive` subscribed to the issue, not to the attempt, so a
person watching agent work watched a static page and reloaded to find out.

Both halves are the same defect from two directions. Prose that restates a row
is a surface asserting something no check can contradict, and a page that shows
a row from mount time asserts a present tense it has no way to keep.

**What the narration claimed that nothing could contradict.** "Assignment claim
released" described a real record — `forge_assignment_credentials.revoked_at`,
which `finish/1` sets in the same transaction that makes an attempt terminal —
but the comment fired for `failed` and `cancelled` only, while the revocation
happens for every terminal state including `completed`. The sentence was
narrower than the fact it described and lived in a different table from it, so
nothing compared them. `report_claim/1` was worse than decorative: `create/1`
failed the whole assignment as `claim_event_failed` when the comment could not
be written, so a failure of the comment table could fail agent work. That
coupling is gone with the comment.

**What the narration carried that no record holds.** The comments were authored
by `author(requesting_principal)`, which returns the requesting agent for an
agent-requested attempt and `nil` for a person's own — so the timeline named an
agent in one case and spoke in a voice with no actor at all in the other. The
derived events carry `actor: nil` and always did. The attempt now publishes
`requester_kind`, `user` or `agent`, at `pulse` (`#149`), which is the kind
TRANSPARENCY-001 admits on a public projection; the specific agent's id is not
restored, because that contract publishes a principal's kind and never its id.
That is the one loss and it is deliberate. Everything else the three comments
said is a column of `forge_assignments` and renders from it.

Existing comments stay and still render. Only new ones stop, and a test holds
the mixed page. One test elsewhere was reading the narration as evidence:
`box_controller_test.exs` proved a linked agent's assignment used the granting
human's conversation by reading the *comment's* `author_agent_id`, beside an
assertion on the attempt's own `requesting_principal` that proved the same
thing from the record. It now asserts the issue carries no comment at all.

**Live means subscribed, and the message carries an id.**
`Assignments.subscribe_attempts/1` and `announce/1` follow the house shape
(#154, `OpenAgentsWeb.LiveRefresh`): the topic is `issue_attempts:{issue_id}`,
the message is `{:attempts_changed, issue_id}`, and it carries nothing else.
Not the state, not the branch, not the row — each of those is disclosed at a
rung by `#149`'s schedule, and a message carrying one would carry it past the
gate that decides the rung. The subscriber marks the panel stale and re-reads
through `refresh_panel/2`, which re-resolves the repository with
`get_visible_repository/2` and rebuilds the attempts at this viewer's own
`WorkDisclosure.viewer/2` rung. A member whose membership is removed while the
socket is open loses the branch on the next announcement, which is the test
that proves the re-read is authorized rather than cached.

Elapsed time is a clock, not a poll: a one-second timer armed only while an
attempt is live, re-reading nothing and recomputing from the attempt's own
start — a field `pulse` publishes, so a reader who cannot see the branch can
still see how long the work has run. The panel now renders for any reader while
an attempt is live; the cancel control and the start form stay behind write
authority.

**Cancelling reaches the terminal path that already exists.**
`Assignments.cancel/2` reads write authority from the attempt's own repository
rather than from the socket, refuses an attempt that already finished, and
calls `finish/1` — so a cancelled attempt revokes its credential, releases its
issue claim, and binds its evidence exactly as a failure does. An attempt that
started and never finished still renders as started and nothing more.

Contract: INVARIANTS.md, ISSUE-004, extended rather than given an id of its own.
Mutation-checked, nine mutations, each red on a named assertion: the page not
subscribing to the attempt topic; `finish/1` announcing nothing; the
announcement carrying the attempt row instead of the id; `cancel/2` skipping the
write-authority read; `cancel/2` dropping the terminal guard; the re-read
skipping `refresh_authority/1`; the cancel control rendering without write
authority; `finish/1` narrating again; and `elapsed/2` returning nothing.

Stage 3 of #10, and Track E's E3. Closes #147.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#147

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 281 · 2026-08-24T05:15:33.507514Z

Changed files

  • modified INVARIANTS.md
  • modified docs/2026-08-23-issue-work-receipt-linkage-design.md
  • modified lib/openagents/forge/assignments.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified test/openagents/forge/assignment_test.exs
  • modified test/openagents_web/controllers/box_controller_test.exs
  • added test/openagents_web/live/issue_live_work_test.exs

Diff

7 files changed, +569 -94

INVARIANTS.md modified +53 -3

@@ -4493,9 +4493,59 @@ is running on instead of failing opaquely. Every other refusal the admission

4493 4493
returns — an offline or revoked computer, a busy target, a protected branch, a
4494 4494
disabled controller — is shown as itself.
4495 4495
4496
**A live attempt is shown, not narrated.** `Assignments.report_claim/1`,
4497
`report/1`, and `report_release/1` wrote the claim, the result, and the release
4498
as Markdown comments on the issue. Every fact in them is a column of
4499
`forge_assignments`, so the timeline derives them and the write path writes
4500
nothing. Comments already written stay and still render; only new ones stop.
4501
4502
That prose was a surface asserting things nothing could contradict. "Assignment
4503
claim released" described the credential revocation, which lives in
4504
`forge_assignment_credentials.revoked_at` and which `finish/1` sets in the same
4505
transaction that makes an attempt terminal — for *every* terminal state, while
4506
the comment fired for `failed` and `cancelled` only. Nothing compared the
4507
sentence to the row. `report_claim/1` was also load-bearing: `create/1` failed
4508
the whole assignment as `claim_event_failed` when the comment could not be
4509
written, so a failure of the comment table could fail agent work. Both are gone.
4510
4511
One thing the prose carried that no record holds is now published as a kind
4512
rather than an id: the comments were authored by the requesting agent, so an
4513
agent-requested attempt named the agent and a person's own attempt named
4514
nobody at all. The attempt's `requester_kind` — `user` or `agent`, at `pulse`
4515
— replaces it under TRANSPARENCY-001's rule that a principal's kind is
4516
published and its id is not. The specific agent's identity is deliberately not
4517
restored.
4518
4519
**The page moves when the attempt moves, and re-reads to find out how.**
4520
`IssueShowLive` subscribes to `issue_attempts:{issue_id}` at mount.
4521
`Assignments.announce/1` broadcasts `{:attempts_changed, issue_id}` and nothing
4522
else — not the state, not the branch, not the row, because each of those is
4523
disclosed at a rung and a message carrying one would carry it past the gate
4524
that decides the rung. The subscriber marks the panel stale and re-reads
4525
through `refresh_panel/2`, which re-resolves the repository with
4526
`Repositories.get_visible_repository/2` and rebuilds the attempts at this
4527
viewer's own `WorkDisclosure.viewer/2` rung, so a viewer whose membership was
4528
removed while the socket was open loses the branch on the next announcement.
4529
Elapsed time is a clock and not a poll: a one-second timer, armed only while an
4530
attempt is live, that re-reads nothing and recomputes from the attempt's own
4531
start.
4532
4533
**Cancelling reaches the same terminal path.** A viewer with write authority
4534
cancels from the issue; `Assignments.cancel/2` reads that authority from the
4535
attempt's own repository rather than from the socket, and reaches `finish/1`,
4536
so a cancelled attempt revokes its credential, releases its issue claim, and
4537
binds its evidence exactly as a failure does. A reader is offered no control
4538
and a crafted event cancels nothing. An attempt that started and never finished
4539
renders as started and nothing more: the timeline invents no terminal event.
4540
4496 4541
Evidence: `OpenAgentsWeb.IssueShowLive`, `OpenAgents.Forge.Assignments`,
4497
`OpenAgents.ComputerAgentJobs`, and
4498
`test/openagents_web/live/issue_start_work_live_test.exs`.
4542
`OpenAgents.ComputerAgentJobs`,
4543
`test/openagents_web/live/issue_start_work_live_test.exs`,
4544
`test/openagents_web/live/issue_live_work_test.exs`, and
4545
`test/openagents/forge/assignment_test.exs`.
4546
4547
(Amended 2026-08-24, issue #147: an attempt was narrated in prose and shown
4548
without moving. It is now shown, live, and narrated nowhere.)
4499 4549
4500 4550
### CAPACITY-001 — Capacity is a bounded, owner-safe quantity projection
4501 4551

@@ -4786,4 +4836,4 @@ contract; the invariant prose above defines the assertion, not the filename.

4786 4836
| FORUM-001 | `test/openagents/forum/legacy_surface_test.exs`, `test/openagents_web/live/forum_live_test.exs`, `test/openagents_web/route_authority_test.exs`, `test/openagents_web/sidebar_state_test.exs` |
4787 4837
| ISSUE-002 | `test/openagents/issues/task_list_test.exs`, `test/openagents/issues/task_references_test.exs`, `test/openagents_web/live/issue_show_live_test.exs` |
4788 4838
| ISSUE-003 | `test/openagents/issues/evidence_test.exs`, `test/openagents_web/controllers/issue_controller_test.exs` |
4789
| ISSUE-004 | `test/openagents_web/live/issue_start_work_live_test.exs` |
4839
| ISSUE-004 | `test/openagents_web/live/issue_live_work_test.exs`, `test/openagents_web/live/issue_start_work_live_test.exs` |
docs/2026-08-23-issue-work-receipt-linkage-design.md modified +35 -9

@@ -69,8 +69,8 @@ Four findings shape the staging:

69 69
   does store and says so in its own moduledoc
70 70
   (`lib/openagents_web/live/issue_show_live.ex:22`). Agent activity reaches
71 71
   the issue as Markdown comments written by `Assignments.report_claim/1`,
72
   `report/1`, and `report_release/1` (`assignments.ex:371`, `:338`, `:356`).
73
   That is precisely what `#69` refuses: "link work jobs and commits through
72
   `report/1`, and `report_release/1` (`assignments.ex:371`, `:338`, `:356`)
73
   — all three retired in stage 3. That is precisely what `#69` refuses: "link work jobs and commits through
74 74
   stable identifiers, not free-form commit messages alone".
75 75
3. **The commit-to-receipt chain is complete and already joined — by sha, not
76 76
   by issue.** `changelog_entries` carries `push_receipt_id`,

@@ -210,11 +210,12 @@ a different fact from an issue whose attempts were not asked for — the same

210 210
distinction `IssueJSON` already draws for the dependency graph
211 211
(`lib/openagents_web/controllers/issue_json.ex:2`).
212 212
213
One duplication is now visible and is deliberate for stage 1: the same
214
attempt appears both as a derived timeline event and as the Markdown comment
215
`Assignments.report/1` writes. Stage 3 retires the comment in favor of the
216
derived event. Doing it in stage 1 would delete history from issues that
217
already carry those comments, which is a migration, not a read.
213
One duplication was visible and deliberate for stage 1: the same attempt
214
appeared both as a derived timeline event and as the Markdown comment
215
`Assignments.report/1` wrote. Stage 3 retired the three comments in favor of
216
the derived events. Doing it in stage 1 would have deleted history from issues
217
that already carry those comments, which is a migration, not a read — so the
218
write path stopped and the existing comments stayed.
218 219
219 220
---
220 221

@@ -397,7 +398,7 @@ policy, a budget, and an executor chosen from the issue rather than from a

397 398
conversation. Nothing new stores work; the button reaches the same admission.
398 399
This is E2, and `#10`'s "start bounded agent work from an issue".
399 400
400
### Stage 3 — live activity, and retiring the narration
401
### Stage 3 — live activity, and retiring the narration (shipped)
401 402
402 403
**Seam:** `IssueShowLive` and `Assignments.report*/1`.
403 404
**Size:** small, plus one backfill decision.

@@ -408,6 +409,19 @@ with write authority, and stop writing the three Markdown comments now that

408 409
the derived events carry the same facts. Existing comments stay; only new ones
409 410
stop. This is E3.
410 411
412
What shipped, and the one thing the prose carried that no record held. The
413
three comments restated columns of `forge_assignments`, with two exceptions.
414
"Assignment claim released" described `forge_assignment_credentials.revoked_at`
415
— a real record in a different table — and fired for `failed` and `cancelled`
416
while `finish/1` revokes for every terminal state, so the sentence was narrower
417
than the fact and nothing compared them. And the comments were authored by the
418
requesting agent, so an agent-requested attempt named the agent while a
419
person's own attempt named nobody; the attempt now publishes `requester_kind`
420
at `pulse` instead, which is the kind TRANSPARENCY-001 admits and not the id it
421
does not. `report_claim/1` was additionally load-bearing — `create/1` failed the
422
assignment as `claim_event_failed` when the comment could not be written — and
423
that coupling is gone with it. See `INVARIANTS.md`, ISSUE-004.
424
411 425
### Stage 4 — bind receipts to the exact commit (shipped)
412 426
413 427
**Seam:** a new `issue_evidence` edge table, and `#130`'s extraction.

@@ -427,7 +441,7 @@ honest bound: `receipts_for/2` scans a window and a sha older than it returns

427 441
empty, so the edge must be written when the receipt is written rather than
428 442
scanned for later. This is E4 and E5.
429 443
430
### Stage 5 — tiers over work and receipts
444
### Stage 5 — tiers over work and receipts (shipped)
431 445
432 446
**Seam:** `ArtifactLink.artifact_types/0` and every issue-timeline read.
433 447
**Size:** small storage change, wide read change.

@@ -438,6 +452,18 @@ route every timeline and API read through `Transparency.allows?/3`. The

438 452
acceptance property is `#70`'s: the same viewer gets the same answer on the
439 453
web page, the API, and an export. This is E6.
440 454
455
What shipped: `OpenAgents.Transparency.WorkDisclosure`, a field-by-field
456
schedule rather than a per-record verdict, because an attempt's branch, a work
457
job's report, and an evidence edge's environment are not one decision. Every
458
column of the three tables is either the source of exactly one scheduled field
459
or a member of that family's never list. There was no artifact-type check
460
constraint to add the members to — `#70` constrained the tier only — so the
461
type and ref-kind vocabularies are constraints now too. `trace` is in the
462
vocabulary with no producer, and the enumeration says so. There is no separate
463
issue export, so the acceptance property is proved over the two surfaces that
464
exist and stated over the projection function, which a future export inherits.
465
See `INVARIANTS.md`, TRANSPARENCY-001.
466
441 467
### Stage 6 — close from verified outcomes
442 468
443 469
**Seam:** a durable claim record and a per-repository policy.
lib/openagents/forge/assignments.ex modified +76 -56

@@ -14,7 +14,6 @@ defmodule OpenAgents.Forge.Assignments do

14 14
  alias OpenAgents.Box.ConversationBox
15 15
  alias OpenAgents.BoxRuns
16 16
  alias OpenAgents.Forge.{Assignment, AssignmentCredential, AssignmentCredentialVault}
17
  alias OpenAgents.Issues
18 17
  alias OpenAgents.Issues.{Evidence, Issue}
19 18
  alias OpenAgents.Repo
20 19
  alias OpenAgents.Conversations

@@ -51,14 +50,8 @@ defmodule OpenAgents.Forge.Assignments do

51 50
           ),
52 51
         {:ok, assignment, plaintext} <-
53 52
           persist_assignment(target_kind, target, repository, issue, branch, principal, attrs) do
54
      case report_claim(assignment) do
55
        {:ok, _comment} ->
56
          start_target(assignment, target, target_kind, plaintext, attrs, owner, conversation)
57
58
        {:error, reason} ->
59
          _ = finish(assignment, "failed", nil, "claim_event_failed")
60
          {:error, reason}
61
      end
53
      _ = announce(assignment)
54
      start_target(assignment, target, target_kind, plaintext, attrs, owner, conversation)
62 55
    end
63 56
  end
64 57

@@ -87,6 +80,7 @@ defmodule OpenAgents.Forge.Assignments do

87 80
              |> Repo.update!()
88 81
          end
89 82
83
        _ = announce(assignment)
90 84
        {:ok, assignment, plaintext}
91 85
92 86
      {:error, reason} ->

@@ -104,6 +98,8 @@ defmodule OpenAgents.Forge.Assignments do

104 98
      |> Assignment.changeset(%{state: "running", started_at: DateTime.utc_now()})
105 99
      |> Repo.update!()
106 100
101
    _ = announce(assignment)
102
107 103
    params = %{
108 104
      "prompt" => attrs[:prompt] || attrs["prompt"] || "",
109 105
      "cwd" => attrs[:cwd] || attrs["cwd"] || "",

@@ -334,8 +330,7 @@ defmodule OpenAgents.Forge.Assignments do

334 330
        # a receipt that already landed. Never load-bearing: an attempt that
335 331
        # finished is finished whether or not its evidence could be written.
336 332
        _ = Evidence.bind_attempt(updated)
337
        _ = report(updated)
338
        if updated.state in ["failed", "cancelled"], do: _ = report_release(updated)
333
        _ = announce(updated)
339 334
        {:ok, updated}
340 335
341 336
      {:ok, {:already_finished, current}} ->

@@ -378,57 +373,86 @@ defmodule OpenAgents.Forge.Assignments do

378 373
    :ok
379 374
  end
380 375
381
  @doc "Returns the assignment credential metadata without exposing its secret."
382
  def credential(%Assignment{id: id}) do
383
    Repo.one(from c in AssignmentCredential, where: c.assignment_id == ^id)
384
  end
376
  @doc """
377
  Subscribes the caller to the attempts on one issue.
385 378
386
  @doc "Reports the terminal result once on the issue timeline."
387
  def report(%Assignment{} = assignment) do
388
    issue = Repo.get!(Issue, assignment.issue_id)
379
  The topic carries announcements, not rows. A subscriber is told that the
380
  attempts on an issue moved and re-reads them through its own authorized
381
  read, so a message can never hand anybody an attempt their repository
382
  membership — or their transparency tier — would have withheld.
383
  """
384
  @spec subscribe_attempts(integer()) :: :ok | {:error, term()}
385
  def subscribe_attempts(issue_id) when is_integer(issue_id),
386
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, attempts_topic(issue_id))
389 387
390
    body =
391
      [
392
        "#{target_label(assignment)} assignment finished.",
393
        "Branch: `#{assignment.terminal_branch || assignment.branch}`.",
394
        "Commit: `#{assignment.terminal_commit || "none reported"}`.",
395
        "Result: `#{assignment.state}`.",
396
        if(assignment.failure_reason, do: "Reason: `#{assignment.failure_reason}`.", else: nil)
397
      ]
398
      |> Enum.reject(&is_nil/1)
399
      |> Enum.join("\n")
388
  @doc "Unsubscribes the caller from the attempts on one issue."
389
  @spec unsubscribe_attempts(integer()) :: :ok
390
  def unsubscribe_attempts(issue_id) when is_integer(issue_id),
391
    do: Phoenix.PubSub.unsubscribe(OpenAgents.PubSub, attempts_topic(issue_id))
400 392
401
    Issues.create_comment(issue, %{body: body}, author(assignment.requesting_principal))
402
  end
393
  @doc """
394
  Announces that the attempts on an issue moved.
403 395
404
  @doc "Reports the release of a failed or cancelled issue claim."
405
  def report_release(%Assignment{} = assignment) do
406
    issue = Repo.get!(Issue, assignment.issue_id)
396
  The message is `{:attempts_changed, issue_id}` and carries nothing else. Not
397
  the state, not the branch, not the attempt: every one of those is disclosed
398
  at a rung, and a message carrying one would carry it past the gate that
399
  decides the rung.
400
  """
401
  @spec announce(Assignment.t()) :: :ok
402
  def announce(%Assignment{issue_id: issue_id}) when is_integer(issue_id),
403
    do:
404
      Phoenix.PubSub.broadcast(
405
        OpenAgents.PubSub,
406
        attempts_topic(issue_id),
407
        {:attempts_changed, issue_id}
408
      )
409
410
  def announce(%Assignment{}), do: :ok
407 411
408
    body =
409
      [
410
        "#{target_label(assignment)} assignment claim released.",
411
        "Branch: `#{assignment.branch}`.",
412
        "Assignment: `#{assignment.id}`."
413
      ]
414
      |> Enum.join("\n")
412
  defp attempts_topic(issue_id), do: "issue_attempts:#{issue_id}"
415 413
416
    Issues.create_comment(issue, %{body: body}, author(assignment.requesting_principal))
414
  @doc """
415
  Cancels a live attempt on behalf of a viewer with write authority.
416
417
  The authority is read from the attempt's own repository rather than from
418
  whatever the caller believes about itself, so a stale socket assign cannot
419
  cancel anybody's work. It reaches `finish/1` — the one terminal path — so a
420
  cancelled attempt revokes its credential, releases its issue claim, and binds
421
  its evidence exactly as a failure does.
422
  """
423
  @spec cancel(String.t(), OpenAgents.Accounts.User.t() | nil) ::
424
          {:ok, Assignment.t()} | {:error, atom()}
425
  def cancel(assignment_id, user) when is_binary(assignment_id) do
426
    case Repo.get(Assignment, assignment_id) do
427
      nil ->
428
        {:error, :assignment_not_found}
429
430
      %Assignment{} = assignment ->
431
        cond do
432
          Assignment.terminal?(assignment) ->
433
            {:error, :assignment_not_live}
434
435
          not writable_by?(assignment, user) ->
436
            {:error, :repository_not_writable}
437
438
          true ->
439
            finish(assignment, "cancelled", nil, "cancelled_by_viewer")
440
        end
441
    end
417 442
  end
418 443
419
  @doc "Reports the claim before the Box run starts."
420
  def report_claim(%Assignment{} = assignment) do
421
    issue = Repo.get!(Issue, assignment.issue_id)
444
  def cancel(_assignment_id, _user), do: {:error, :assignment_not_found}
422 445
423
    body =
424
      [
425
        "#{target_label(assignment)} assignment claimed.",
426
        "Branch: `#{assignment.branch}`.",
427
        "Assignment: `#{assignment.id}`."
428
      ]
429
      |> Enum.join("\n")
446
  defp writable_by?(%Assignment{repository_id: repository_id}, user) do
447
    case Repo.get(Repository, repository_id) do
448
      %Repository{} = repository -> OpenAgents.Repositories.writable?(repository, user)
449
      nil -> false
450
    end
451
  end
430 452
431
    Issues.create_comment(issue, %{body: body}, author(assignment.requesting_principal))
453
  @doc "Returns the assignment credential metadata without exposing its secret."
454
  def credential(%Assignment{id: id}) do
455
    Repo.one(from c in AssignmentCredential, where: c.assignment_id == ^id)
432 456
  end
433 457
434 458
  defp persist_assignment(target_kind, target, repository, issue, branch, principal, attrs) do

@@ -732,9 +756,5 @@ defmodule OpenAgents.Forge.Assignments do

732 756
733 757
  defp usable?(_), do: false
734 758
735
  defp author(%{"actor_type" => "agent", "actor_id" => id}), do: Repo.get!(Agent, id)
736
  defp author(_), do: nil
737
  defp target_label(%Assignment{target_kind: "computer"}), do: "Computer"
738
  defp target_label(_), do: "Box"
739 759
  defp digest(value), do: :crypto.hash(:sha256, value)
740 760
end
lib/openagents_web/live/issue_show_live.ex modified +129 -10

@@ -78,6 +78,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

78 78
    # move as well.
79 79
    if connected?(socket) do
80 80
      Repositories.subscribe_issues(repository.id)
81
      Assignments.subscribe_attempts(issue.id)
81 82
      Labels.subscribe_labels(repository.id)
82 83
      Milestones.subscribe_milestones(repository.id)
83 84
    end

@@ -186,6 +187,12 @@ defmodule OpenAgentsWeb.IssueShowLive do

186 187
    end)
187 188
  end
188 189
190
  def handle_event("cancel_work", _params, socket) do
191
    with_authority(socket, :can_write, "You cannot cancel work on this issue.", fn socket ->
192
      {:noreply, cancel_work(socket)}
193
    end)
194
  end
195
189 196
  def handle_event("toggle_label", %{"name" => name}, socket) do
190 197
    with_authority(
191 198
      socket,

@@ -283,6 +290,27 @@ defmodule OpenAgentsWeb.IssueShowLive do

283 290
      when message in [:labels_changed, :milestones_changed],
284 291
      do: {:noreply, socket}
285 292
293
  # An attempt moved. The message carries the issue id and nothing else, so
294
  # this re-reads through `refresh_panel/2` — which re-resolves the repository
295
  # for this viewer and rebuilds the attempts at this viewer's rung — rather
296
  # than assigning anything the announcement handed it.
297
  def handle_info({:attempts_changed, issue_id}, socket)
298
      when issue_id == socket.assigns.issue.id,
299
      do: {:noreply, LiveRefresh.mark_stale(socket, :issue, &refresh_panel/2)}
300
301
  def handle_info({:attempts_changed, _other_issue}, socket), do: {:noreply, socket}
302
303
  # The clock, not a poll. It re-reads nothing: elapsed time is `now` minus the
304
  # attempt's own start, and the attempt itself moves only when its topic says
305
  # so. The timer stops itself when the attempt ends.
306
  def handle_info(:attempt_tick, socket) do
307
    if socket.assigns[:live_attempt] do
308
      {:noreply, socket |> assign(:now, DateTime.utc_now()) |> schedule_tick()}
309
    else
310
      {:noreply, assign(socket, :ticking?, false)}
311
    end
312
  end
313
286 314
  def handle_info(:live_refresh, socket),
287 315
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}
288 316

@@ -577,6 +605,59 @@ defmodule OpenAgentsWeb.IssueShowLive do

577 605
578 606
  defp refusal(_reason, _socket), do: "Work was refused."
579 607
608
  # The control is hidden without write authority and the event is refused
609
  # without it too: `Assignments.cancel/2` reads the authority from the
610
  # attempt's own repository, so a socket that kept a stale `can_write` cannot
611
  # end anybody's work.
612
  defp cancel_work(socket) do
613
    case live_attempt(socket.assigns[:attempts] || []) do
614
      %{id: id} ->
615
        case Assignments.cancel(id, socket.assigns.current_user) do
616
          {:ok, _assignment} ->
617
            socket
618
            |> put_flash(:info, "Work cancelled.")
619
            |> load(socket.assigns.issue)
620
621
          {:error, reason} ->
622
            put_flash(socket, :error, cancel_refusal(reason))
623
        end
624
625
      nil ->
626
        put_flash(socket, :error, "No work is running on this issue.")
627
    end
628
  end
629
630
  defp cancel_refusal(:assignment_not_live), do: "That work already finished."
631
  defp cancel_refusal(:assignment_not_found), do: "That work no longer exists."
632
633
  defp cancel_refusal(:repository_not_writable),
634
    do: "You cannot cancel work on this repository."
635
636
  defp cancel_refusal(_reason), do: "Work could not be cancelled."
637
638
  defp schedule_tick(socket) do
639
    Process.send_after(self(), :attempt_tick, 1_000)
640
    socket
641
  end
642
643
  # How long the live attempt has been running, from the fields `pulse`
644
  # already publishes. A reader who cannot see the branch can still see this.
645
  defp elapsed(%{} = attempt, %DateTime{} = now) do
646
    case attempt[:started_at] || attempt[:admitted_at] do
647
      %DateTime{} = start -> humanize_seconds(max(DateTime.diff(now, start, :second), 0))
648
      _unknown -> nil
649
    end
650
  end
651
652
  defp elapsed(_attempt, _now), do: nil
653
654
  defp humanize_seconds(seconds) when seconds < 60, do: "#{seconds}s"
655
656
  defp humanize_seconds(seconds) when seconds < 3_600,
657
    do: "#{div(seconds, 60)}m #{rem(seconds, 60)}s"
658
659
  defp humanize_seconds(seconds), do: "#{div(seconds, 3_600)}h #{div(rem(seconds, 3_600), 60)}m"
660
580 661
  # One place rebuilds everything derived from the issue, so a write cannot
581 662
  # leave the timeline describing the previous version of the page.
582 663
  defp load(socket, issue) do

@@ -607,6 +688,27 @@ defmodule OpenAgentsWeb.IssueShowLive do

607 688
    |> assign(:form, to_form(Issues.change_issue(issue)))
608 689
    |> assign(:events, timeline(issue, comments, attempts, references, syncs, base))
609 690
    |> assign(:subscribed?, subscribed?(issue, socket.assigns.current_user))
691
    |> assign(:now, DateTime.utc_now())
692
    |> arm_tick()
693
  end
694
695
  # The clock is armed only while an attempt is live, so an issue nobody is
696
  # working on costs no timer at all, and only on the transition into a live
697
  # attempt, so a re-read cannot stack timers.
698
  defp arm_tick(socket) do
699
    live? = not is_nil(socket.assigns[:live_attempt])
700
    ticking? = socket.assigns[:ticking?] == true
701
702
    cond do
703
      live? and not ticking? and connected?(socket) ->
704
        socket |> assign(:ticking?, true) |> schedule_tick()
705
706
      not live? ->
707
        assign(socket, :ticking?, false)
708
709
      true ->
710
        socket
711
    end
610 712
  end
611 713
612 714
  defp subscribed?(_issue, nil), do: false

@@ -823,19 +925,36 @@ defmodule OpenAgentsWeb.IssueShowLive do

823 925
824 926
          <%!-- Outside the properties panel too, and for the opposite
825 927
          reason: everything in that panel edits the issue record, and this
826
          starts an execution somewhere else. It needs write authority, which
827
          is what `Assignments.create/1` re-checks server-side, so the control
828
          is hidden and the event is refused rather than only hidden. --%>
829
          <section :if={@can_write} id="issue-work" class="properties-panel__group">
928
          starts an execution somewhere else. Starting and cancelling need
929
          write authority, which `Assignments.create/1` and
930
          `Assignments.cancel/2` re-check server-side, so each control is
931
          hidden and its event is refused rather than only hidden. That a live
932
          attempt exists, and how long it has run, is not a write and is shown
933
          to any reader who reached the issue. --%>
934
          <section :if={@can_write or @live_attempt} id="issue-work" class="properties-panel__group">
830 935
            <h3 class="properties-panel__heading">Agent work</h3>
831 936
832
            <p :if={@live_attempt} class="properties-panel__none" id="issue-work-live">
833
              Work is running{live_attempt_branch(@live_attempt)}. One attempt may be live
834
              on an issue at a time.
835
            </p>
937
            <div :if={@live_attempt} id="issue-work-live">
938
              <p class="properties-panel__none">
939
                Work is <span id="issue-work-state">{@live_attempt.state}</span>{live_attempt_branch(
940
                  @live_attempt
941
                )}, for <span id="issue-work-elapsed">{elapsed(@live_attempt, @now)}</span>.
942
                One attempt may be live on an issue at a time.
943
              </p>
944
              <.button
945
                :if={@can_write}
946
                id="issue-work-cancel"
947
                variant={:ghost}
948
                size={:sm}
949
                data-tone="danger"
950
                phx-click="cancel_work"
951
              >
952
                Cancel work
953
              </.button>
954
            </div>
836 955
837 956
            <p
838
              :if={is_nil(@live_attempt) and @work_computers == []}
957
              :if={@can_write and is_nil(@live_attempt) and @work_computers == []}
839 958
              class="properties-panel__none"
840 959
              id="issue-work-unavailable"
841 960
            >

@@ -844,7 +963,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

844 963
            </p>
845 964
846 965
            <.form
847
              :if={is_nil(@live_attempt) and @work_computers != []}
966
              :if={@can_write and is_nil(@live_attempt) and @work_computers != []}
848 967
              for={@work_form}
849 968
              id="issue-work-form"
850 969
              phx-change="preview_work"
test/openagents/forge/assignment_test.exs modified +34 -14

@@ -146,7 +146,7 @@ defmodule OpenAgents.Forge.AssignmentTest do

146 146
             GitReceivePack.refs(pkt_line("malformed\n") <> "0000")
147 147
  end
148 148
149
  test "assignment claim and release are visible on the issue timeline" do
149
  test "an attempt narrates nothing, and its terminal record carries what the prose did" do
150 150
    user = repository_user_fixture("assignment-timeline")
151 151
152 152
    {:ok, repository} =

@@ -189,21 +189,41 @@ defmodule OpenAgents.Forge.AssignmentTest do

189 189
      })
190 190
      |> Repo.insert!()
191 191
192
    assert {:ok, _comment} = OpenAgents.Forge.Assignments.report_claim(assignment)
193
194
    assert {:ok, _finished} =
192
    %AssignmentCredential{}
193
    |> AssignmentCredential.changeset(%{
194
      assignment_id: assignment.id,
195
      token_digest: :crypto.hash(:sha256, "oa_assignment_timeline"),
196
      last_four: "abcd",
197
      repository_id: repository.id,
198
      branch: "agent/timeline",
199
      expires_at: DateTime.add(now, 60, :second)
200
    })
201
    |> Repo.insert!()
202
203
    assert {:ok, finished} =
195 204
             OpenAgents.Forge.Assignments.finish(assignment, "failed", nil, "test")
196 205
197
    bodies =
198
      Repo.all(
199
        from comment in Comment,
200
          where: comment.issue_id == ^issue.id,
201
          order_by: [asc: comment.created_at],
202
          select: comment.body
203
      )
204
205
    assert Enum.any?(bodies, &String.contains?(&1, "Box assignment claimed."))
206
    assert Enum.any?(bodies, &String.contains?(&1, "claim released."))
206
    # The claim, the result, and the release were three Markdown comments the
207
    # attempt wrote about itself. Every fact in them is in this row, so the
208
    # timeline derives them and the write path writes nothing.
209
    assert Repo.all(from comment in Comment, where: comment.issue_id == ^issue.id) == []
210
211
    assert finished.state == "failed"
212
    assert finished.failure_reason == "test"
213
    assert finished.finished_at
214
    assert finished.terminal_branch == "agent/timeline"
215
216
    # "Claim released" described the credential revocation, and this is the
217
    # record it described. `finish/1` revokes in the same transaction that
218
    # makes the attempt terminal, for every terminal state — the comment fired
219
    # for only two of the three, and nothing compared the two.
220
    assert [%{revoked_at: revoked_at}] =
221
             Repo.all(
222
               from credential in AssignmentCredential,
223
                 where: credential.assignment_id == ^assignment.id
224
             )
225
226
    assert revoked_at
207 227
    assert Repo.get!(OpenAgents.Issues.Issue, issue.id).state == "open"
208 228
  end
209 229
test/openagents_web/controllers/box_controller_test.exs modified +5 -2

@@ -255,8 +255,11 @@ defmodule OpenAgentsWeb.BoxControllerTest do

255 255
    assert assignment.requesting_principal["type"] == "agent"
256 256
    assert assignment.requesting_principal["id"] == agent.id
257 257
    assert Agents.box_control_owner(agent).id == owner.id
258
    assert %Comment{author_agent_id: agent_id} = Repo.get_by(Comment, issue_id: issue.id)
259
    assert agent_id == agent.id
258
259
    # The agent that asked is on the attempt, which is where it belongs. It
260
    # used to be read from the author of a narration comment; that comment is
261
    # retired (#147) and the issue carries none.
262
    assert is_nil(Repo.get_by(Comment, issue_id: issue.id))
260 263
  end
261 264
262 265
  test "a foreign box returns 404 without an outbound provider request", %{conn: conn} do
test/openagents_web/live/issue_live_work_test.exs added +237

@@ -0,0 +1,237 @@

1
defmodule OpenAgentsWeb.IssueLiveWorkTest do
2
  @moduledoc """
3
  Stage 3 of `#10`: show a running attempt live, and stop narrating it.
4
5
  The narration went because it was a surface asserting things nothing could
6
  contradict. Three Markdown comments restated `forge_assignments` in prose,
7
  one of them — "claim released" — describing a credential revocation in a
8
  different table, for two of the three terminal states, while `finish/1`
9
  revokes for all three. Nothing compared the sentence to the row.
10
11
  What replaces it is the row. These tests hold the three properties that makes
12
  possible: the page moves when the attempt moves, the announcement that moves
13
  it carries an id and nothing else, and the re-read it triggers goes through
14
  the viewer's own authorization rather than through whatever the socket last
15
  believed.
16
  """
17
  use OpenAgentsWeb.ConnCase, async: false
18
19
  import Ecto.Query
20
  import Phoenix.LiveViewTest
21
22
  alias OpenAgents.Forge.{Assignment, Assignments}
23
  alias OpenAgents.Issues
24
  alias OpenAgents.Repo
25
  alias OpenAgents.Repositories
26
27
  @sha String.duplicate("ab", 20)
28
29
  setup %{conn: conn} do
30
    repository = Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
31
    member = github_user("issue-live-work-member")
32
    {:ok, _} = Repositories.add_member(repository, member, "maintainer")
33
34
    {:ok, issue} = Issues.create_issue(repository, %{title: "Watch the work"})
35
36
    %{
37
      conn: Plug.Test.init_test_session(conn, %{"user_id" => member.id}),
38
      member: member,
39
      repository: repository,
40
      issue: issue
41
    }
42
  end
43
44
  defp path(issue), do: ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}"
45
46
  describe "the announcement" do
47
    test "carries the issue id and nothing else", context do
48
      :ok = Assignments.subscribe_attempts(context.issue.id)
49
      attempt = attempt(context)
50
51
      Assignments.announce(attempt)
52
53
      issue_id = context.issue.id
54
      assert_receive {:attempts_changed, ^issue_id}
55
56
      # Nothing else arrives, so nothing carried the branch, the state, the
57
      # revision, or the row. A subscriber that wants any of those must re-read
58
      # for them, which is the only place a gate can run.
59
      refute_receive {:attempts_changed, _issue_id, _anything}
60
      refute_receive %Assignment{}
61
    end
62
63
    test "reaches only the issue it names", context do
64
      {:ok, other} = Issues.create_issue(context.repository, %{title: "Elsewhere"})
65
      :ok = Assignments.subscribe_attempts(other.id)
66
67
      Assignments.announce(attempt(context))
68
69
      refute_receive {:attempts_changed, _issue_id}
70
    end
71
  end
72
73
  describe "a running attempt on the page" do
74
    test "renders its state and how long it has been running", context do
75
      attempt(context, started_at: DateTime.add(DateTime.utc_now(), -90, :second))
76
77
      {:ok, view, _html} = live(context.conn, path(context.issue))
78
79
      assert has_element?(view, "#issue-work-live")
80
      assert render(view) =~ "running"
81
      assert has_element?(view, "#issue-work-elapsed")
82
      assert render(view) =~ "1m 3"
83
    end
84
85
    test "lands its terminal event without a reload", context do
86
      attempt = attempt(context)
87
88
      {:ok, view, _html} = live(context.conn, path(context.issue))
89
90
      assert has_element?(view, "#issue-work-live")
91
      refute render(view) =~ "finished this work"
92
93
      # The production terminal path, which announces on the topic the page
94
      # subscribed to at mount. Nothing here touches the view.
95
      {:ok, _finished} = Assignments.finish(attempt, "completed", @sha)
96
97
      html = render(view)
98
      assert html =~ "finished this work at #{String.slice(@sha, 0, 7)}"
99
      refute has_element?(view, "#issue-work-live")
100
    end
101
102
    test "an attempt that started and never finished renders as started only", context do
103
      attempt(context)
104
105
      {:ok, view, _html} = live(context.conn, path(context.issue))
106
107
      html = render(view)
108
      assert html =~ "started work on a box"
109
      refute html =~ "finished this work"
110
      refute html =~ "stopped this work"
111
      refute html =~ "cancelled this work"
112
    end
113
  end
114
115
  describe "cancelling" do
116
    test "a viewer with write authority ends the attempt through the terminal path",
117
         context do
118
      attempt = attempt(context)
119
120
      {:ok, view, _html} = live(context.conn, path(context.issue))
121
122
      assert has_element?(view, "#issue-work-cancel")
123
      render_click(view, "cancel_work", %{})
124
125
      cancelled = Repo.get!(Assignment, attempt.id)
126
      assert cancelled.state == "cancelled"
127
      assert cancelled.failure_reason == "cancelled_by_viewer"
128
      assert cancelled.finished_at
129
130
      refute has_element?(view, "#issue-work-live")
131
      assert render(view) =~ "cancelled this work"
132
    end
133
134
    test "a reader without write authority is offered no control and refused the event",
135
         context do
136
      attempt = attempt(context)
137
138
      {:ok, view, _html} = live(build_conn(), path(context.issue))
139
140
      refute has_element?(view, "#issue-work-cancel")
141
      render_click(view, "cancel_work", %{})
142
143
      assert Repo.get!(Assignment, attempt.id).state == "running"
144
    end
145
146
    test "is refused for an attempt that already finished", context do
147
      attempt = attempt(context)
148
      {:ok, _} = Assignments.finish(attempt, "completed", @sha)
149
150
      assert {:error, :assignment_not_live} = Assignments.cancel(attempt.id, context.member)
151
    end
152
153
    test "reads its authority from the attempt's own repository", context do
154
      attempt = attempt(context)
155
      stranger = github_user("issue-live-work-stranger")
156
157
      assert {:error, :repository_not_writable} = Assignments.cancel(attempt.id, stranger)
158
      assert {:error, :repository_not_writable} = Assignments.cancel(attempt.id, nil)
159
      assert Repo.get!(Assignment, attempt.id).state == "running"
160
    end
161
  end
162
163
  describe "the re-read the announcement triggers" do
164
    test "runs through this viewer's own authority, not the one it mounted with",
165
         context do
166
      attempt(context)
167
168
      {:ok, view, _html} = live(context.conn, path(context.issue))
169
170
      # A member reads the attempt at `ledger`, so the branch is on the page.
171
      assert render(view) =~ "agent/watched"
172
173
      # Authority changes underneath a mounted socket. Nothing tells the view.
174
      Repo.delete_all(
175
        from membership in OpenAgents.Repositories.Membership,
176
          where:
177
            membership.repository_id == ^context.repository.id and
178
              membership.user_id == ^context.member.id
179
      )
180
181
      Assignments.announce(Repo.get!(Assignment, attempt_id(context)))
182
183
      # The re-read went through `Repositories.get_visible_repository/2` and
184
      # `WorkDisclosure.viewer/2` again, so the reader dropped to `pulse` and
185
      # the branch left the page. A message that carried the attempt would have
186
      # put it back.
187
      html = render(view)
188
      refute html =~ "agent/watched"
189
      refute has_element?(view, "#issue-work-cancel")
190
    end
191
  end
192
193
  defp attempt_id(context) do
194
    Repo.one!(from a in Assignment, where: a.issue_id == ^context.issue.id, select: a.id)
195
  end
196
197
  defp attempt(context, opts \\ []) do
198
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
199
    started_at = Keyword.get(opts, :started_at, now) |> DateTime.truncate(:microsecond)
200
201
    %Assignment{}
202
    |> Assignment.changeset(%{
203
      target_kind: "box",
204
      conversation_box_id: box(context).id,
205
      repository_id: context.repository.id,
206
      issue_id: context.issue.id,
207
      requesting_principal: %{
208
        "type" => "user",
209
        "id" => context.member.id,
210
        "actor_type" => "user",
211
        "actor_id" => context.member.id
212
      },
213
      branch: "agent/watched",
214
      state: "running",
215
      admitted_at: started_at,
216
      started_at: started_at,
217
      deadline_at: DateTime.add(now, 3_600, :second)
218
    })
219
    |> Repo.insert!()
220
  end
221
222
  defp box(context) do
223
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.member)
224
225
    {:ok, box} =
226
      %OpenAgents.Box.ConversationBox{}
227
      |> OpenAgents.Box.ConversationBox.changeset(%{
228
        conversation_id: conversation.id,
229
        box_id: "bx_live_work_#{System.unique_integer([:positive])}",
230
        state: "ready",
231
        setup_status: "done"
232
      })
233
      |> Repo.insert()
234
235
    box
236
  end
237
end

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