Commit an effect with the intent that asked for it

8a22bf0ac482 · AtlantisPleb · · parent 5b465f014aaa

Commit an effect with the intent that asked for it

A work job used to commit its row and then ask Horde for a worker, from the
same process on the same node. A crash in that gap left a committed `queued`
job that nothing was executing, and nothing noticed:
`recover_interrupted_jobs/0` sweeps at boot and never after, so the job sat
until that node restarted. That is the failure class T3 Code's teardown calls
best-effort live reactor loss of committed work
(`docs/2026-08-24-coder-first-cloud-complements.md` section 3).

The `effects` table closes the gap. `OpenAgents.Effects.enqueue/2` runs inside
the caller's transaction, so the intent and its effect are durable together or
not at all — a rollback leaves nothing owed. After the commit any node's
worker may claim it: `claim_batch/2` updates candidates conditionally, so
racing workers take disjoint sets; `reclaim_expired/1` returns what a dead
worker held; `fail/2` backs off and stops at `maximum_attempts` rather than
looping; `complete/1` is idempotent, so the worker whose lease expired
mid-flight reports success without writing a second completion.

Two digests, because they answer different questions. The idempotency key
identifies the effect and is derived from its kind and source, never its
payload, so it is the same string on every retry and node, and a unique index
makes a repeated enqueue one row. The payload digest fingerprints the content,
so a reused key carrying different content is refused instead of silently
answered with the first result — the half T3 skipped.

`OpenAgents.Work.start_job/1` and its delegation, scv, and continual-learning
siblings are the converted call site. They commit the job and its
`work.launch_worker` effect in one transaction, keep the inline launch as a
fast path, and retire their own effect when it succeeds. A launch that did not
happen is delivered by `OpenAgents.Effects.Handlers.WorkLaunch`, which is safe
to redeliver three ways over: the worker is a Horde singleton, a terminal job
needs no worker, and a job that no longer exists owes nothing. A transient
placement error is therefore no longer a job failed on the spot with
`worker_start_failed`.

EFFECT-002 writes down the rule the outbox exists to keep: six acknowledgment
milestones stay distinct, and a `thread_events` sequence is a transcript
position, not an execution claim and not a completion claim. The schema holds
that apart rather than convention — `effects_status_shape_check` refuses a
claimed row that carries a completion and a terminal row that still holds a
lease.

The other post-commit effects on this plane — turn starts, account-run
provider launches, thread event broadcasts, terminal workspace cleanup —
remain best-effort and are named as such in EFFECT-001.

Also records `20260824203139` in the migration lineage map, which the lineage
test has wanted since that migration landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
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.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/runtime.exs
  • modified docs/taxonomy.md
  • added lib/openagents/effects.ex
  • added lib/openagents/effects/effect.ex
  • added lib/openagents/effects/handler.ex
  • added lib/openagents/effects/handlers/work_launch.ex
  • added lib/openagents/effects/registry.ex
  • added lib/openagents/effects/worker.ex
  • modified lib/openagents/runtime_supervisor.ex
  • modified lib/openagents/work.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824204740_create_effects.exs
  • added test/openagents/effects/work_launch_test.exs
  • added test/openagents/effects_test.exs
  • added test/support/effects_echo_handler.ex
  • added test/support/effects_recording_launch.ex

Diff

18 files changed, +1868 -40

INVARIANTS.md modified +103

@@ -1226,6 +1226,107 @@ Evidence: `OpenAgents.Inference.Models`, `OpenAgentsWeb.ModelCatalogController`,

1226 1226
`OpenAgentsWeb.InferenceProxyControllerTest`, and
1227 1227
`OpenAgentsWeb.ThreadControllerTest`.
1228 1228
1229
## Durable effects
1230
1231
### EFFECT-001 — An effect commits with the intent that asked for it, and is delivered under lease
1232
1233
Status: Current
1234
1235
An intent that asks for something outside its own transaction — launch a
1236
worker, call a provider, start a delegation — records the asking in `effects`
1237
inside the transaction that writes the intent. `OpenAgents.Effects.enqueue/2`
1238
performs one insert and participates in the caller's ambient transaction, so a
1239
rollback takes the effect with it. Either the intent and its effect are both
1240
durable or neither is; there is no window in which the system has promised work
1241
it holds no record of owing.
1242
1243
This closes a specific failure, not a hypothetical one. `OpenAgents.Work` used
1244
to commit the `work_jobs` row and then ask Horde for a worker, from the same
1245
process on the same node. A crash between those two steps left a committed
1246
`queued` job that nothing was executing, and nothing noticed:
1247
`OpenAgents.Work.recover_interrupted_jobs/0` sweeps at boot and never after, so
1248
the job sat until that node restarted. The teardown corpus names this class —
1249
"best-effort live reactor" loss of committed work — in
1250
`docs/2026-08-24-coder-first-cloud-complements.md` section 3.
1251
1252
- **Delivery is leased, and a lease is a pair.** `claim_batch/2` takes
1253
  candidates and updates them conditionally, so two workers racing over one
1254
  batch take disjoint sets and no effect is handed to two workers to run twice.
1255
  `effects_lease_pair_check` refuses an owner without an expiry and an expiry
1256
  without an owner, so there is no lease nobody can reclaim.
1257
- **A dead worker loses nothing.** `reclaim_expired/1` returns every effect
1258
  whose lease ran out to `pending`, on this node or any other. The attempt it
1259
  spent is not refunded, so a handler that reliably kills its worker still
1260
  reaches `maximum_attempts` and stops rather than looping forever.
1261
- **Redelivery is safe by construction.** The idempotency key is derived from
1262
  the effect's kind and its source, not its payload, so it is the same string
1263
  on every retry, every node, and after every restart; a unique index on it
1264
  makes a repeated enqueue one row. `complete/1` is idempotent, so the worker
1265
  whose lease expired mid-flight reports success without writing a second
1266
  completion or contradicting the record.
1267
- **A reused key with different content is refused.** `payload_digest`
1268
  fingerprints the content separately from the key that identifies the effect.
1269
  Enqueuing a known key with a different payload returns `:payload_conflict`
1270
  rather than silently answering the second caller with the first caller's
1271
  effect — the fix `docs/2026-08-24-coder-first-cloud-complements.md` section 3
1272
  names as the one T3 skipped.
1273
- **An unknown kind is a refusal, not a no-op.** `OpenAgents.Effects.Registry`
1274
  is the admitted map of kind to handler; the claim query offers only kinds in
1275
  it, and dispatching an unregistered kind fails the effect. Nothing turns a
1276
  payload string into a module or an atom at runtime. An outbox that quietly
1277
  drops what it does not recognize is the behaviour this table replaces.
1278
1279
The converted call site is the work-job launch: `OpenAgents.Work.start_job/1`
1280
and its `delegation`, `scv`, and `continual_learning` siblings commit the job
1281
row and its `work.launch_worker` effect in one transaction, then try the launch
1282
inline and retire their own effect on success. A launch that did not happen is
1283
delivered by `OpenAgents.Effects.Handlers.WorkLaunch`, which is idempotent three
1284
times over: the worker is a Horde cluster singleton, a terminal job needs no
1285
worker, and a job that no longer exists owes nothing. Other post-commit effects
1286
on this plane — turn starts, account-run provider launches, thread event
1287
broadcasts, terminal workspace cleanup — are not yet on the outbox and remain
1288
best-effort.
1289
1290
Evidence: `OpenAgents.Effects`, `OpenAgents.Effects.Effect`,
1291
`OpenAgents.Effects.Worker`, `OpenAgents.Effects.Registry`,
1292
`OpenAgents.Effects.Handlers.WorkLaunch`, `OpenAgents.Work`,
1293
`priv/repo/migrations/20260824204740_create_effects.exs`,
1294
`test/openagents/effects_test.exs`, and
1295
`test/openagents/effects/work_launch_test.exs`.
1296
1297
### EFFECT-002 — Six acknowledgment milestones stay distinct
1298
1299
Status: Current
1300
1301
A single sequence number never stands for more than one of these facts:
1302
1303
1. **Command admitted** — the caller's intent passed admission.
1304
2. **Event committed** — the intent row is durable, and any effect it asked for
1305
   is durable with it.
1306
3. **Effect claimed** — a worker holds a lease and said it would try.
1307
4. **Effect completed** — the handler returned successfully.
1308
5. **Turn quiesced** — the work the effect started has stopped.
1309
6. **Work verified** — someone accepted the result.
1310
1311
A `thread_events` sequence is a transcript position. It is not an execution
1312
claim and it is not a completion claim, and nothing reads it as either. The
1313
`effects` table keeps milestones three and four apart in the schema rather than
1314
by convention: `effects_status_shape_check` requires a `claimed` row to hold a
1315
lease and an owner with no `completed_at`, and a terminal row to hold a
1316
`completed_at` and no lease, so "a worker took this" and "this ran" cannot
1317
collapse into one column. `source_sequence` records the transcript position the
1318
effect came from, beside the status and never in place of it.
1319
1320
Milestones one, five, and six are owned elsewhere and are named here so that
1321
nothing later borrows an effect status to mean them: admission belongs to each
1322
intent's own path, quiescence to the thread and turn plane
1323
(`OpenAgents.Conversations` terminal turn state), and verification to receipts.
1324
1325
Evidence: `OpenAgents.Effects`,
1326
`priv/repo/migrations/20260824204740_create_effects.exs`,
1327
`test/openagents/effects_test.exs`, and
1328
`test/openagents/effects/work_launch_test.exs`.
1329
1229 1330
## Tool authority and execution
1230 1331
1231 1332
### TOOL-001 — A turn uses one immutable tool catalog

@@ -5052,6 +5153,8 @@ contract; the invariant prose above defines the assertion, not the filename.

5052 5153
| PROVENANCE-001 | `test/openagents/turn_provenance_test.exs` |
5053 5154
| PROVIDER-001 | `test/openagents/providers/provider_contract_test.exs`, `test/openagents/turn_provider_events_test.exs`, `test/openagents/dependency_boundary_test.exs` |
5054 5155
| PROVIDER-002 | `test/openagents/inference/models_test.exs`, `test/openagents_web/controllers/model_catalog_controller_test.exs`, `test/openagents_web/controllers/inference_proxy_controller_test.exs`, `test/openagents_web/controllers/thread_controller_test.exs` |
5156
| EFFECT-001 | `test/openagents/effects_test.exs`, `test/openagents/effects/work_launch_test.exs` |
5157
| EFFECT-002 | `test/openagents/effects_test.exs`, `test/openagents/effects/work_launch_test.exs` |
5055 5158
| TOOL-001 | `test/openagents/tools/registry_and_runner_test.exs` |
5056 5159
| COLLECTIVE-001 | `test/openagents/collective_test.exs` |
5057 5160
| COLLECTIVE-002 | `test/openagents/collective_generalizer_test.exs` |
config/config.exs modified +11

@@ -58,6 +58,17 @@ config :openagents,

58 58
  ],
59 59
  work_workers_enabled: false,
60 60
  work: [enabled: false],
61
  # The durable effect outbox (EFFECT-001). The table is always written; the
62
  # worker that drains it is what this gates, because a host that should not
63
  # execute effects must not claim one.
64
  effects: [
65
    worker_enabled: false,
66
    interval_ms: 1_000,
67
    batch_limit: 20,
68
    lease_seconds: 120,
69
    backoff_base_ms: 1_000,
70
    backoff_ceiling_ms: 300_000
71
  ],
61 72
  scv_codex: [
62 73
    enabled: false,
63 74
    execution_reaper_enabled: false,
config/runtime.exs modified +8

@@ -444,6 +444,14 @@ if config_env() == :prod and runtime_role == :web do

444 444
    voice_retention_enabled: voice_retention_enabled,
445 445
    work: work,
446 446
    work_workers_enabled: work_enabled,
447
    # The outbox drains wherever work workers run: the effects it carries today
448
    # are the launches those workers are owed.
449
    effects:
450
      Keyword.put(
451
        Application.fetch_env!(:openagents, :effects),
452
        :worker_enabled,
453
        work_enabled
454
      ),
447 455
    scv_codex: scv_codex,
448 456
    scv_deploy: scv_deploy,
449 457
    semantic_index: semantic_index,
docs/taxonomy.md modified +7

@@ -450,6 +450,13 @@ an older target, and it promotes nothing.

450 450
work job is not automatically a `deep_work.v1` job. A Computer delegation is a
451 451
`work_jobs` row of kind `delegation`.
452 452
453
**Effect (`effects`)** — one thing a committed intent asked the system to do
454
outside its own transaction, recorded by `OpenAgents.Effects` in the same
455
transaction as the intent and delivered later under a lease. It is the durable
456
outbox, not a message and not a broadcast: a broadcast that nobody receives is
457
gone, while an effect that nobody ran is still owed. An effect's `claimed`
458
status means a worker said it would try; only `done` means it ran (EFFECT-002).
459
453 460
### Delegation targets
454 461
455 462
**Delegation** — one unit of work this application hands to a substrate it
lib/openagents/effects.ex added +421

@@ -0,0 +1,421 @@

1
defmodule OpenAgents.Effects do
2
  @moduledoc """
3
  The durable effect outbox (EFFECT-001).
4
5
  An intent that asks for something outside its own transaction — launch a
6
  worker, call a provider, start a delegation — commits the asking with the
7
  intent. `enqueue/2` is called *inside* the caller's transaction; that is the
8
  whole point. Either the intent row and its effect row both exist or neither
9
  does, so there is no window in which the system has promised work it has no
10
  record of owing.
11
12
  After the commit the effect is anyone's to run. `claim_batch/2` takes a lease
13
  with a conditional update, so two workers racing for one effect produce one
14
  winner. `complete/1` and `fail/2` record the outcome. `reclaim_expired/1`
15
  returns to the queue whatever a dead worker was holding.
16
17
  ## The six milestones
18
19
  This module deliberately keeps apart the facts that a single sequence number
20
  would conflate (EFFECT-002):
21
22
    * **command admitted** — the caller's intent passed admission. Not here.
23
    * **event committed** — the intent row, and this effect row with it, are
24
      durable. `enqueue/2` returning inside a committed transaction.
25
    * **effect claimed** — a worker holds a lease and is about to try.
26
      `status = "claimed"`, `claimed_at`, `lease_owner`.
27
    * **effect completed** — the handler returned successfully.
28
      `status = "done"`, `completed_at`.
29
    * **turn quiesced** — the work the effect started has stopped. Owned by the
30
      thread and turn plane, not by this table.
31
    * **work verified** — someone accepted the result. Owned by receipts.
32
33
  A `thread_events` sequence is a transcript position, not an execution claim
34
  and not a completion claim. Nothing here reads one as either.
35
  """
36
37
  import Ecto.Query
38
39
  alias OpenAgents.Effects.Effect
40
  alias OpenAgents.Provenance.Canonical
41
  alias OpenAgents.Repo
42
43
  @default_maximum_attempts 5
44
  @default_lease_seconds 120
45
  @default_batch_limit 20
46
  @default_backoff_base_ms 1_000
47
  @default_backoff_ceiling_ms 300_000
48
  @maximum_error_bytes 4_000
49
50
  @typedoc "Why an enqueue was refused."
51
  @type enqueue_error :: :payload_conflict | Ecto.Changeset.t()
52
53
  @doc """
54
  Record an effect the caller's transaction is committing.
55
56
  Call this inside the transaction that writes the intent. It performs one
57
  insert and participates in the ambient transaction, so a rollback takes the
58
  effect with it and nothing is delivered for work that never happened.
59
60
  ## Attributes
61
62
    * `:payload` — the handler's whole input, a map. Required.
63
    * `:source_kind` / `:source_id` — the committed intent that asked.
64
      Required.
65
    * `:source_sequence` — the intent's transcript position, where it has one.
66
      Recorded as evidence; never read as an execution or completion claim.
67
    * `:idempotency_key` — the effect's identity. Defaults to a deterministic
68
      key over the kind and source, so the same intent enqueued twice is one
69
      effect and one delivery.
70
    * `:maximum_attempts`, `:available_at` — delivery policy.
71
72
  Enqueuing the same key twice with the same payload returns the existing
73
  effect: an honest retry is not a second effect. Enqueuing the same key with a
74
  *different* payload returns `{:error, :payload_conflict}` rather than
75
  answering the second caller with the first caller's effect.
76
  """
77
  @spec enqueue(String.t(), map() | keyword()) :: {:ok, Effect.t()} | {:error, enqueue_error()}
78
  def enqueue(kind, attributes) when is_binary(kind) and is_list(attributes),
79
    do: enqueue(kind, Map.new(attributes))
80
81
  def enqueue(kind, attributes) when is_binary(kind) and is_map(attributes) do
82
    now = fetch(attributes, :now, DateTime.utc_now())
83
    payload = fetch(attributes, :payload, %{})
84
    source_kind = fetch(attributes, :source_kind, nil)
85
    source_id = attributes |> fetch(:source_id, nil) |> to_source_id()
86
    source_sequence = fetch(attributes, :source_sequence, nil)
87
88
    row = %{
89
      kind: kind,
90
      payload: payload,
91
      payload_digest: payload_digest(payload),
92
      source_kind: source_kind,
93
      source_id: source_id,
94
      source_sequence: source_sequence,
95
      idempotency_key:
96
        fetch(
97
          attributes,
98
          :idempotency_key,
99
          idempotency_key(kind, source_kind, source_id, source_sequence)
100
        ),
101
      maximum_attempts: fetch(attributes, :maximum_attempts, @default_maximum_attempts),
102
      available_at: fetch(attributes, :available_at, now)
103
    }
104
105
    changeset = Effect.enqueue_changeset(row)
106
107
    # `on_conflict` rather than a bare insert on purpose: a unique-violation
108
    # error would abort the caller's whole transaction, which would turn an
109
    # idempotent retry of the intent into a failure of the intent.
110
    insert =
111
      Repo.insert(changeset,
112
        on_conflict: {:replace, [:updated_at]},
113
        conflict_target: :idempotency_key,
114
        returning: true
115
      )
116
117
    case insert do
118
      {:ok, %Effect{payload_digest: digest} = effect} ->
119
        if digest == row.payload_digest, do: {:ok, effect}, else: {:error, :payload_conflict}
120
121
      {:error, changeset} ->
122
        {:error, changeset}
123
    end
124
  end
125
126
  @doc """
127
  The deterministic identity of an effect.
128
129
  Derived from the kind and the intent that asked for it, so the same intent
130
  produces the same key on every retry, on every node, after every restart.
131
  A handler receives it and may use it as its own idempotency token.
132
  """
133
  @spec idempotency_key(String.t(), String.t() | nil, String.t() | nil, integer() | nil) ::
134
          String.t()
135
  def idempotency_key(kind, source_kind, source_id, source_sequence \\ nil) do
136
    parts = [kind, source_kind || "", source_id || "", sequence_part(source_sequence)]
137
    "effect:" <> Canonical.sha256(Enum.join(parts, "|"))
138
  end
139
140
  @doc "The canonical fingerprint of an effect payload."
141
  @spec payload_digest(map()) :: String.t()
142
  def payload_digest(payload) when is_map(payload), do: "sha256:" <> Canonical.digest!(payload)
143
144
  @doc """
145
  Claim up to `:limit` deliverable effects for `worker`, taking a lease.
146
147
  Candidate ids are read first and then updated conditionally, so two workers
148
  racing over the same candidates each take a disjoint set: the loser's update
149
  matches zero rows because the status it required is no longer there. This is
150
  the same shape `OpenAgents.Deployments.claim_run/2` uses, for the same
151
  reason.
152
153
  Claiming is not completing. A claimed effect is one a worker said it would
154
  try, and nothing more (EFFECT-002).
155
  """
156
  @spec claim_batch(String.t(), keyword()) :: [Effect.t()]
157
  def claim_batch(worker, options \\ []) when is_binary(worker) do
158
    now = Keyword.get(options, :now, DateTime.utc_now())
159
    limit = Keyword.get(options, :limit, @default_batch_limit)
160
    lease_seconds = Keyword.get(options, :lease_seconds, lease_seconds())
161
    expires_at = DateTime.add(now, lease_seconds, :second)
162
    kinds = Keyword.get(options, :kinds)
163
164
    candidates =
165
      Effect
166
      |> where([e], e.status == "pending" and e.available_at <= ^now)
167
      |> then(fn query ->
168
        if kinds, do: where(query, [e], e.kind in ^kinds), else: query
169
      end)
170
      |> order_by([e], asc: e.available_at, asc: e.inserted_at)
171
      |> limit(^limit)
172
      |> select([e], e.id)
173
      |> Repo.all()
174
175
    case candidates do
176
      [] ->
177
        []
178
179
      ids ->
180
        {_claimed, effects} =
181
          Repo.update_all(
182
            from(e in Effect,
183
              where: e.id in ^ids and e.status == "pending" and e.available_at <= ^now,
184
              select: e
185
            ),
186
            set: [
187
              status: "claimed",
188
              lease_owner: worker,
189
              lease_expires_at: expires_at,
190
              claimed_at: now,
191
              updated_at: now
192
            ],
193
            inc: [attempts: 1]
194
          )
195
196
        effects
197
    end
198
  end
199
200
  @doc """
201
  Record that an effect's handler succeeded.
202
203
  Idempotent under redelivery: completing an effect that is already `done`
204
  returns it unchanged rather than writing a second completion. A worker whose
205
  lease expired mid-flight, and whose effect another worker has since finished,
206
  therefore reports success without contradicting the record.
207
  """
208
  @spec complete(Effect.t() | String.t()) :: {:ok, Effect.t()} | {:error, :not_found}
209
  def complete(%Effect{id: id}), do: complete(id)
210
211
  def complete(id) when is_binary(id) do
212
    now = DateTime.utc_now()
213
214
    {_count, updated} =
215
      Repo.update_all(
216
        from(e in Effect, where: e.id == ^id and e.status != "done", select: e),
217
        set: [
218
          status: "done",
219
          lease_owner: nil,
220
          lease_expires_at: nil,
221
          last_error: nil,
222
          completed_at: now,
223
          updated_at: now
224
        ]
225
      )
226
227
    case updated do
228
      [%Effect{} = effect] -> {:ok, effect}
229
      [] -> already_done(id)
230
    end
231
  end
232
233
  @doc """
234
  Record that an effect's handler failed.
235
236
  Below `maximum_attempts` the effect returns to `pending` with `available_at`
237
  pushed out by exponential backoff, and the lease is released so any worker
238
  may take the next attempt. At the ceiling it becomes terminally `failed` and
239
  stops being delivered — an effect that cannot be run must stop pretending it
240
  will be, so that something else can notice.
241
  """
242
  @spec fail(Effect.t() | String.t(), term()) :: {:ok, Effect.t()} | {:error, :not_found}
243
  def fail(%Effect{id: id}, reason), do: fail(id, reason)
244
245
  def fail(id, reason) when is_binary(id) do
246
    now = DateTime.utc_now()
247
    message = error_message(reason)
248
249
    case Repo.get(Effect, id) do
250
      nil ->
251
        {:error, :not_found}
252
253
      %Effect{status: "done"} = effect ->
254
        {:ok, effect}
255
256
      %Effect{attempts: attempts, maximum_attempts: maximum} = effect
257
      when attempts >= maximum ->
258
        set_fields(effect, %{
259
          status: "failed",
260
          lease_owner: nil,
261
          lease_expires_at: nil,
262
          last_error: message,
263
          completed_at: now,
264
          updated_at: now
265
        })
266
267
      %Effect{attempts: attempts} = effect ->
268
        set_fields(effect, %{
269
          status: "pending",
270
          lease_owner: nil,
271
          lease_expires_at: nil,
272
          last_error: message,
273
          available_at: DateTime.add(now, backoff_ms(attempts), :millisecond),
274
          updated_at: now
275
        })
276
    end
277
  end
278
279
  @doc """
280
  Return to the queue every effect whose lease has run out.
281
282
  A worker that died holding a lease loses nothing: the effect it claimed
283
  becomes deliverable again, on this node or any other. The attempt it already
284
  spent is not refunded, so a handler that reliably kills its worker still
285
  reaches `maximum_attempts` and stops.
286
287
  Returns the number of effects reclaimed.
288
  """
289
  @spec reclaim_expired(keyword()) :: non_neg_integer()
290
  def reclaim_expired(options \\ []) do
291
    now = Keyword.get(options, :now, DateTime.utc_now())
292
293
    {count, _rows} =
294
      Repo.update_all(
295
        from(e in Effect,
296
          where: e.status == "claimed" and e.lease_expires_at <= ^now
297
        ),
298
        set: [
299
          status: "pending",
300
          lease_owner: nil,
301
          lease_expires_at: nil,
302
          available_at: now,
303
          updated_at: now
304
        ]
305
      )
306
307
    count
308
  end
309
310
  @doc "Fetch one effect by id."
311
  @spec get(String.t()) :: Effect.t() | nil
312
  def get(id) when is_binary(id), do: Repo.get(Effect, id)
313
314
  @doc "Fetch the effect an intent enqueued, by its deterministic key."
315
  @spec get_by_key(String.t()) :: Effect.t() | nil
316
  def get_by_key(key) when is_binary(key), do: Repo.get_by(Effect, idempotency_key: key)
317
318
  @doc "Every effect a given intent asked for, oldest first."
319
  @spec for_source(String.t(), String.t()) :: [Effect.t()]
320
  def for_source(source_kind, source_id) when is_binary(source_kind) do
321
    Repo.all(
322
      from e in Effect,
323
        where: e.source_kind == ^source_kind and e.source_id == ^to_source_id(source_id),
324
        order_by: [asc: e.inserted_at, asc: e.id]
325
    )
326
  end
327
328
  @doc "How many effects hold each status, for operators and tests."
329
  @spec counts() :: %{String.t() => non_neg_integer()}
330
  def counts do
331
    Effect
332
    |> group_by([e], e.status)
333
    |> select([e], {e.status, count(e.id)})
334
    |> Repo.all()
335
    |> Map.new()
336
  end
337
338
  @doc """
339
  A bounded token naming why an effect failed, safe to log.
340
341
  The durable `last_error` column holds the detail, redacted; a log line holds
342
  only this. A reason's shape decides the token: an atom is itself, a tagged
343
  tuple is its tag, anything else is `unknown`. Nothing derived from a payload
344
  reaches a log through here.
345
  """
346
  @spec error_code(term()) :: String.t()
347
  def error_code(reason) when is_atom(reason), do: bounded_code(reason)
348
  def error_code(tag) when is_tuple(tag) and tuple_size(tag) > 0, do: error_code(elem(tag, 0))
349
  def error_code(_reason), do: "unknown"
350
351
  @doc "The lease length a claim takes by default."
352
  @spec lease_seconds() :: pos_integer()
353
  def lease_seconds, do: setting(:lease_seconds, @default_lease_seconds)
354
355
  @doc "How long an effect waits before its `attempts`-th retry."
356
  @spec backoff_ms(non_neg_integer()) :: non_neg_integer()
357
  def backoff_ms(attempts) when is_integer(attempts) and attempts >= 0 do
358
    base = setting(:backoff_base_ms, @default_backoff_base_ms)
359
    ceiling = setting(:backoff_ceiling_ms, @default_backoff_ceiling_ms)
360
    exponent = max(attempts - 1, 0) |> min(16)
361
    min(base * Integer.pow(2, exponent), ceiling)
362
  end
363
364
  defp already_done(id) do
365
    case Repo.get(Effect, id) do
366
      %Effect{} = effect -> {:ok, effect}
367
      nil -> {:error, :not_found}
368
    end
369
  end
370
371
  defp set_fields(%Effect{} = effect, changes) do
372
    {_count, [updated]} =
373
      Repo.update_all(
374
        from(e in Effect, where: e.id == ^effect.id, select: e),
375
        set: Map.to_list(changes)
376
      )
377
378
    {:ok, updated}
379
  end
380
381
  defp fetch(attributes, key, default) do
382
    case Map.fetch(attributes, key) do
383
      {:ok, nil} -> default
384
      {:ok, value} -> value
385
      :error -> Map.get(attributes, to_string(key), default)
386
    end
387
  end
388
389
  defp to_source_id(nil), do: nil
390
  defp to_source_id(value) when is_binary(value), do: value
391
  defp to_source_id(value) when is_integer(value), do: Integer.to_string(value)
392
393
  defp sequence_part(nil), do: ""
394
  defp sequence_part(sequence) when is_integer(sequence), do: Integer.to_string(sequence)
395
396
  # A handler's failure reason can carry whatever the far side said, including
397
  # a URL with a credential in it. It is bounded and redacted before it becomes
398
  # a durable column, once, here — not at each of the places that read it.
399
  defp error_message(reason) when is_binary(reason),
400
    do: reason |> OpenAgents.LogSafety.redact() |> String.slice(0, @maximum_error_bytes)
401
402
  defp error_message(reason),
403
    do:
404
      reason
405
      |> inspect(limit: 50, printable_limit: 2_000)
406
      |> OpenAgents.LogSafety.redact()
407
      |> String.slice(0, @maximum_error_bytes)
408
409
  defp bounded_code(atom) do
410
    atom
411
    |> Atom.to_string()
412
    |> String.replace(~r/[^A-Za-z0-9_.]/, "_")
413
    |> String.slice(0, 64)
414
  end
415
416
  defp setting(key, default) do
417
    :openagents
418
    |> Application.get_env(:effects, [])
419
    |> Keyword.get(key, default)
420
  end
421
end
lib/openagents/effects/effect.ex added +91

@@ -0,0 +1,91 @@

1
defmodule OpenAgents.Effects.Effect do
2
  @moduledoc """
3
  One durable effect: something a committed intent asked the system to do.
4
5
  The row is written inside the transaction that writes the intent, so the two
6
  cannot disagree. Everything a handler needs is in `payload`, because a
7
  handler that reads anything else is not replayable.
8
9
  `payload_digest` fingerprints the content and `idempotency_key` identifies
10
  the effect. Those are different questions, and conflating them is the mistake
11
  `docs/2026-08-24-coder-first-cloud-complements.md` section 3 names: a reused
12
  id with different content must be refused, not silently answered with the
13
  first result.
14
  """
15
16
  use Ecto.Schema
17
18
  import Ecto.Changeset
19
20
  @primary_key {:id, :binary_id, autogenerate: true}
21
  @timestamps_opts [type: :utc_datetime_usec]
22
23
  @statuses ~w(pending claimed done failed)
24
25
  schema "effects" do
26
    field :kind, :string
27
    field :payload, :map
28
    field :payload_digest, :string
29
30
    field :source_kind, :string
31
    field :source_id, :string
32
    field :source_sequence, :integer
33
34
    field :idempotency_key, :string
35
36
    field :status, :string, default: "pending"
37
    field :attempts, :integer, default: 0
38
    field :maximum_attempts, :integer, default: 5
39
    field :available_at, :utc_datetime_usec
40
41
    field :lease_owner, :string
42
    field :lease_expires_at, :utc_datetime_usec
43
    field :last_error, :string
44
45
    field :claimed_at, :utc_datetime_usec
46
    field :completed_at, :utc_datetime_usec
47
48
    timestamps()
49
  end
50
51
  @type t :: %__MODULE__{}
52
53
  @doc "The statuses an effect row may hold."
54
  @spec statuses() :: [String.t()]
55
  def statuses, do: @statuses
56
57
  @doc false
58
  def enqueue_changeset(attributes) do
59
    %__MODULE__{}
60
    |> cast(attributes, [
61
      :kind,
62
      :payload,
63
      :payload_digest,
64
      :source_kind,
65
      :source_id,
66
      :source_sequence,
67
      :idempotency_key,
68
      :maximum_attempts,
69
      :available_at
70
    ])
71
    |> validate_required([
72
      :kind,
73
      :payload,
74
      :payload_digest,
75
      :source_kind,
76
      :source_id,
77
      :idempotency_key,
78
      :available_at
79
    ])
80
    |> validate_length(:kind, min: 1, max: 80)
81
    |> validate_length(:source_kind, min: 1, max: 80)
82
    |> validate_length(:source_id, min: 1, max: 255)
83
    |> validate_number(:maximum_attempts, greater_than_or_equal_to: 1)
84
    |> put_change(:status, "pending")
85
    |> put_change(:attempts, 0)
86
    |> unique_constraint(:idempotency_key)
87
    |> check_constraint(:status, name: :effects_status_check)
88
    |> check_constraint(:payload, name: :effects_payload_present_check)
89
    |> check_constraint(:status, name: :effects_status_shape_check)
90
  end
91
end
lib/openagents/effects/handler.ex added +24

@@ -0,0 +1,24 @@

1
defmodule OpenAgents.Effects.Handler do
2
  @moduledoc """
3
  What a durable effect's kind resolves to.
4
5
  A handler is given the effect and its deterministic idempotency key, and
6
  nothing else. Everything it needs is in `effect.payload`, because a handler
7
  that reads ambient state is not replayable, and a redelivered effect is a
8
  replay.
9
10
  The key is stable across retries, restarts, and nodes, so a handler that
11
  reaches something outside this system can hand it that key and let the far
12
  side dedupe. A handler that cannot do that must be idempotent some other way:
13
  `run/2` will be called more than once for one effect whenever a worker dies
14
  between doing the work and recording that it did.
15
16
  Returning `{:error, reason}` — or raising — is a retry, until the effect's
17
  `maximum_attempts`.
18
  """
19
20
  alias OpenAgents.Effects.Effect
21
22
  @callback run(effect :: Effect.t(), idempotency_key :: String.t()) ::
23
              :ok | {:ok, term()} | {:error, term()}
24
end
lib/openagents/effects/handlers/work_launch.ex added +78

@@ -0,0 +1,78 @@

1
defmodule OpenAgents.Effects.Handlers.WorkLaunch do
2
  @moduledoc """
3
  Starts the worker a committed `work_jobs` row is owed (EFFECT-001).
4
5
  `OpenAgents.Work.start_job/1` and its siblings commit the job row and the
6
  effect that asks for its worker in one transaction, then try the launch
7
  inline. This handler is what runs when that inline attempt did not happen or
8
  did not succeed: the node died in the gap, Horde refused, the cluster was
9
  mid-relocation.
10
11
  Redelivery is safe three times over. The worker is a Horde cluster singleton,
12
  so a second `start_child` for a job already running returns
13
  `{:already_started, pid}`, which `OpenAgents.Work.ensure_worker/2` reports as
14
  success. A job that reached a terminal status needs no worker and the effect
15
  completes without one. A job that no longer exists — the conversation was
16
  deleted under DATA-004 — is likewise nothing owed, not a failure to retry.
17
  """
18
19
  @behaviour OpenAgents.Effects.Handler
20
21
  alias OpenAgents.Effects.Effect
22
  alias OpenAgents.Work
23
  alias OpenAgents.Work.Job
24
25
  # The payload names its worker by a bounded string this module admits.
26
  # Nothing turns a payload value into a module or an atom at runtime.
27
  @workers %{
28
    "job" => OpenAgents.Work.JobServer,
29
    "delegation" => OpenAgents.Work.DelegationServer,
30
    "scv" => OpenAgents.Work.ScvServer,
31
    "continual_learning" => OpenAgents.Work.ContinualLearningServer
32
  }
33
34
  @doc "The worker names an effect payload may carry."
35
  @spec worker_names() :: [String.t()]
36
  def worker_names, do: @workers |> Map.keys() |> Enum.sort()
37
38
  @doc "Resolve a payload's worker name to its server module."
39
  @spec worker(String.t()) :: {:ok, module()} | {:error, :unknown_worker}
40
  def worker(name) when is_binary(name) do
41
    case Map.fetch(@workers, name) do
42
      {:ok, module} -> {:ok, module}
43
      :error -> {:error, :unknown_worker}
44
    end
45
  end
46
47
  @impl OpenAgents.Effects.Handler
48
  def run(%Effect{payload: payload}, _idempotency_key) do
49
    with {:ok, job_id} <- fetch(payload, "job_id"),
50
         {:ok, name} <- fetch(payload, "worker"),
51
         {:ok, server} <- worker(name) do
52
      launch(server, job_id)
53
    end
54
  end
55
56
  defp launch(server, job_id) do
57
    case Work.get_job(job_id) do
58
      nil ->
59
        :ok
60
61
      %Job{status: status} when status not in ~w(queued running) ->
62
        :ok
63
64
      %Job{} ->
65
        case Work.ensure_worker(server, job_id) do
66
          {:ok, _pid} -> :ok
67
          {:error, reason} -> {:error, reason}
68
        end
69
    end
70
  end
71
72
  defp fetch(payload, key) do
73
    case Map.fetch(payload, key) do
74
      {:ok, value} when is_binary(value) and value != "" -> {:ok, value}
75
      _missing -> {:error, {:invalid_payload, key}}
76
    end
77
  end
78
end
lib/openagents/effects/registry.ex added +42

@@ -0,0 +1,42 @@

1
defmodule OpenAgents.Effects.Registry do
2
  @moduledoc """
3
  Which module runs which kind of effect.
4
5
  The map is configuration, not a scan: an effect kind with no handler is a
6
  refusal, never a silent no-op, because an outbox that quietly drops an effect
7
  it does not recognize is exactly the "best-effort" behaviour the outbox
8
  exists to replace.
9
10
  Kinds are bounded strings admitted here. Nothing turns a payload string into
11
  a module or an atom at runtime.
12
  """
13
14
  @default_handlers %{
15
    "work.launch_worker" => OpenAgents.Effects.Handlers.WorkLaunch
16
  }
17
18
  @doc "Every admitted effect kind and its handler."
19
  @spec handlers() :: %{String.t() => module()}
20
  def handlers do
21
    configured =
22
      :openagents
23
      |> Application.get_env(:effects, [])
24
      |> Keyword.get(:handlers, %{})
25
      |> Map.new()
26
27
    Map.merge(@default_handlers, configured)
28
  end
29
30
  @doc "Every effect kind this release can run."
31
  @spec kinds() :: [String.t()]
32
  def kinds, do: handlers() |> Map.keys() |> Enum.sort()
33
34
  @doc "Resolve one kind to its handler."
35
  @spec fetch(String.t()) :: {:ok, module()} | {:error, :unknown_kind}
36
  def fetch(kind) when is_binary(kind) do
37
    case Map.fetch(handlers(), kind) do
38
      {:ok, handler} -> {:ok, handler}
39
      :error -> {:error, :unknown_kind}
40
    end
41
  end
42
end
lib/openagents/effects/worker.ex added +167

@@ -0,0 +1,167 @@

1
defmodule OpenAgents.Effects.Worker do
2
  @moduledoc """
3
  Drives committed effects to a terminal state (EFFECT-001).
4
5
  The worker owns no state that matters. Everything it needs is in the
6
  database: the effect, its lease, its attempt count, its last error. Losing
7
  the worker, or the machine it runs on, therefore loses no effect —
8
  `OpenAgents.Effects.reclaim_expired/1` returns whatever it was holding to the
9
  queue, and any other node's worker picks it up.
10
11
  One pass does, in order:
12
13
    1. Reclaim effects whose lease expired, so a dead worker's work comes back.
14
    2. Claim a bounded batch under a fresh lease.
15
    3. Dispatch each to its handler, with the effect's deterministic
16
       idempotency key, and record `complete/1` or `fail/2`.
17
18
  Claiming and completing are separate records on purpose (EFFECT-002). A
19
  claimed effect is one a worker said it would try; it is not evidence that
20
  anything ran, and it is never evidence that anything finished.
21
22
  Tests and operators drive the loop through `tick/1`, so no test has to sleep
23
  and no operator has to guess whether a pass happened.
24
  """
25
26
  use GenServer
27
28
  require Logger
29
30
  alias OpenAgents.Effects
31
  alias OpenAgents.Effects.Effect
32
  alias OpenAgents.Effects.Registry
33
34
  @default_interval 1_000
35
36
  @typedoc "What one pass did."
37
  @type pass :: %{
38
          reclaimed: non_neg_integer(),
39
          claimed: non_neg_integer(),
40
          completed: non_neg_integer(),
41
          failed: non_neg_integer()
42
        }
43
44
  @doc "Start the worker loop."
45
  @spec start_link(keyword()) :: GenServer.on_start()
46
  def start_link(options \\ []) do
47
    {name, options} = Keyword.pop(options, :name, __MODULE__)
48
    GenServer.start_link(__MODULE__, options, name: name)
49
  end
50
51
  @doc "Run one pass synchronously, returning what it did."
52
  @spec tick(GenServer.server()) :: pass()
53
  def tick(server \\ __MODULE__), do: GenServer.call(server, :tick, 60_000)
54
55
  @doc """
56
  Run one pass without a running worker.
57
58
  Exposed so a test, a recovery path, or a one-off operator command can drain
59
  the outbox without standing a process up first.
60
  """
61
  @spec run_once(keyword()) :: pass()
62
  def run_once(options \\ []) do
63
    identity = Keyword.get_lazy(options, :identity, &default_identity/0)
64
    limit = Keyword.get(options, :limit, 20)
65
    lease_seconds = Keyword.get(options, :lease_seconds, Effects.lease_seconds())
66
67
    reclaimed = Effects.reclaim_expired()
68
69
    claimed =
70
      Effects.claim_batch(identity,
71
        limit: limit,
72
        lease_seconds: lease_seconds,
73
        kinds: Registry.kinds()
74
      )
75
76
    {completed, failed} =
77
      Enum.reduce(claimed, {0, 0}, fn effect, {done, dead} ->
78
        case dispatch(effect) do
79
          :ok -> {done + 1, dead}
80
          :error -> {done, dead + 1}
81
        end
82
      end)
83
84
    %{reclaimed: reclaimed, claimed: length(claimed), completed: completed, failed: failed}
85
  end
86
87
  @doc """
88
  Run one claimed effect's handler and record the outcome.
89
90
  Exposed so a caller that already holds an effect can drive it without racing
91
  the claim query.
92
  """
93
  @spec dispatch(Effect.t()) :: :ok | :error
94
  def dispatch(%Effect{} = effect) do
95
    case run_handler(effect) do
96
      :ok ->
97
        {:ok, _effect} = Effects.complete(effect)
98
        :ok
99
100
      {:ok, _result} ->
101
        {:ok, _effect} = Effects.complete(effect)
102
        :ok
103
104
      {:error, reason} ->
105
        # The log carries a bounded code, never the payload: the durable
106
        # `last_error` column is where the detail belongs, redacted once on the
107
        # way in.
108
        Logger.warning(
109
          "effect_failed kind=#{effect.kind} effect=#{effect.id} " <>
110
            "attempt=#{effect.attempts} code=#{Effects.error_code(reason)}"
111
        )
112
113
        {:ok, _effect} = Effects.fail(effect, reason)
114
        :error
115
    end
116
  end
117
118
  @impl GenServer
119
  def init(options) do
120
    state = %{
121
      identity: Keyword.get_lazy(options, :identity, &default_identity/0),
122
      interval: Keyword.get(options, :interval, @default_interval),
123
      limit: Keyword.get(options, :limit, 20),
124
      lease_seconds: Keyword.get(options, :lease_seconds, Effects.lease_seconds()),
125
      poll: Keyword.get(options, :poll, true)
126
    }
127
128
    if state.poll, do: schedule(state)
129
    {:ok, state}
130
  end
131
132
  @impl GenServer
133
  def handle_call(:tick, _from, state), do: {:reply, pass(state), state}
134
135
  @impl GenServer
136
  def handle_info(:tick, state) do
137
    _pass = pass(state)
138
    schedule(state)
139
    {:noreply, state}
140
  end
141
142
  defp pass(state) do
143
    run_once(identity: state.identity, limit: state.limit, lease_seconds: state.lease_seconds)
144
  end
145
146
  defp run_handler(%Effect{} = effect) do
147
    case Registry.fetch(effect.kind) do
148
      {:ok, handler} ->
149
        try do
150
          handler.run(effect, effect.idempotency_key)
151
        rescue
152
          error -> {:error, {:raised, Exception.message(error)}}
153
        catch
154
          kind, reason -> {:error, {kind, inspect(reason, limit: 20)}}
155
        end
156
157
      {:error, :unknown_kind} ->
158
        {:error, {:unknown_kind, effect.kind}}
159
    end
160
  end
161
162
  defp schedule(%{interval: interval}), do: Process.send_after(self(), :tick, interval)
163
164
  defp default_identity do
165
    "#{node()}/#{:erlang.phash2(self())}"
166
  end
167
end
lib/openagents/runtime_supervisor.ex modified +20

@@ -44,6 +44,7 @@ defmodule OpenAgents.RuntimeSupervisor do

44 44
        # provider already did. It deploys nothing on its own.
45 45
        OpenAgents.Deployments.Providers.Fake
46 46
      ] ++
47
        maybe_effect_worker() ++
47 48
        maybe_scv_execution_reaper() ++
48 49
        maybe_forge() ++
49 50
        maybe_semantic_worker() ++

@@ -57,6 +58,25 @@ defmodule OpenAgents.RuntimeSupervisor do

57 58
    Supervisor.init(children, strategy: :one_for_one)
58 59
  end
59 60
61
  # The durable effect outbox's drain loop (EFFECT-001). The table is written
62
  # wherever an intent commits; this is what claims and dispatches, so it is
63
  # gated the same way the deployment worker is — a host that should not
64
  # execute effects must not take a lease on one.
65
  defp maybe_effect_worker do
66
    effects = Application.get_env(:openagents, :effects, [])
67
68
    if Keyword.get(effects, :worker_enabled, false) do
69
      [
70
        {OpenAgents.Effects.Worker,
71
         interval: Keyword.get(effects, :interval_ms, 1_000),
72
         limit: Keyword.get(effects, :batch_limit, 20),
73
         lease_seconds: Keyword.get(effects, :lease_seconds, 120)}
74
      ]
75
    else
76
      []
77
    end
78
  end
79
60 80
  defp maybe_scv_execution_reaper do
61 81
    if Application.fetch_env!(:openagents, :scv_codex)[:execution_reaper_enabled] do
62 82
      [OpenAgents.SCV.ExecutionReaper]
lib/openagents/work.ex modified +87 -39

@@ -32,16 +32,7 @@ defmodule OpenAgents.Work do

32 32
  a job reference while the work continues server-side.
33 33
  """
34 34
  def start_job(attributes) when is_map(attributes) do
35
    with {:ok, job} <- create_job(attributes) do
36
      case start_worker(OpenAgents.Work.JobServer, job.id) do
37
        {:ok, _pid} ->
38
          {:ok, job}
39
40
        {:error, reason} ->
41
          _failure = finish_job(job.id, "failed", error_code: "worker_start_failed")
42
          {:error, reason}
43
      end
44
    end
35
    admit_and_launch(attributes, "job")
45 36
  end
46 37
47 38
  @doc """

@@ -89,16 +80,9 @@ defmodule OpenAgents.Work do

89 80
  the live rail and reporting back when done.
90 81
  """
91 82
  def start_delegation(attributes) when is_map(attributes) do
92
    with {:ok, job} <- create_job(Map.put(attributes, :kind, "delegation")) do
93
      case start_worker(OpenAgents.Work.DelegationServer, job.id) do
94
        {:ok, _pid} ->
95
          {:ok, job}
96
97
        {:error, reason} ->
98
          _failure = finish_job(job.id, "failed", error_code: "worker_start_failed")
99
          {:error, reason}
100
      end
101
    end
83
    attributes
84
    |> Map.put(:kind, "delegation")
85
    |> admit_and_launch("delegation")
102 86
  end
103 87
104 88
  @doc """

@@ -120,16 +104,9 @@ defmodule OpenAgents.Work do

120 104
  the row and starts the worker.
121 105
  """
122 106
  def start_scv(attributes) when is_map(attributes) do
123
    with {:ok, job} <- create_job(Map.put(attributes, :kind, "scv")) do
124
      case start_worker(OpenAgents.Work.ScvServer, job.id) do
125
        {:ok, _pid} ->
126
          {:ok, job}
127
128
        {:error, reason} ->
129
          _failure = finish_job(job.id, "failed", error_code: "worker_start_failed")
130
          {:error, reason}
131
      end
132
    end
107
    attributes
108
    |> Map.put(:kind, "scv")
109
    |> admit_and_launch("scv")
133 110
  end
134 111
135 112
  @doc """

@@ -142,18 +119,85 @@ defmodule OpenAgents.Work do

142 119
  the row and starts the worker.
143 120
  """
144 121
  def start_continual_learning(attributes) when is_map(attributes) do
145
    with {:ok, job} <- create_job(Map.put(attributes, :kind, "continual_learning")) do
146
      case start_worker(OpenAgents.Work.ContinualLearningServer, job.id) do
122
    attributes
123
    |> Map.put(:kind, "continual_learning")
124
    |> admit_and_launch("continual_learning")
125
  end
126
127
  # Commit the job and the launch it is owed in one transaction, then try the
128
  # launch inline (EFFECT-001).
129
  #
130
  # The transaction is the point. Before the outbox, the job row committed and
131
  # the Horde child was started afterwards, from the same process, on the same
132
  # node; a crash in that gap left a committed `queued` job that nothing was
133
  # executing and nothing would notice until that node booted again, because
134
  # `recover_interrupted_jobs/0` runs at boot and never after. Now the effect
135
  # row commits with the job, so any node's outbox worker can start what this
136
  # one promised.
137
  #
138
  # The inline launch stays because it is fast and almost always succeeds; the
139
  # outbox is what makes it safe for it to fail. On success the effect is
140
  # completed here, so the ordinary path writes exactly one extra row and
141
  # retires it. On failure the effect stays pending and the outbox retries it,
142
  # which is why the job is no longer failed with `worker_start_failed` on the
143
  # spot: a transient placement error is not a dead job any more.
144
  defp admit_and_launch(attributes, worker_name) do
145
    {:ok, server} = OpenAgents.Effects.Handlers.WorkLaunch.worker(worker_name)
146
147
    with {:ok, %{job: job, effect: effect}} <- commit_job_with_launch(attributes, worker_name) do
148
      broadcast_job(job)
149
150
      case start_worker(server, job.id) do
147 151
        {:ok, _pid} ->
152
          {:ok, _completed} = OpenAgents.Effects.complete(effect)
148 153
          {:ok, job}
149 154
150 155
        {:error, reason} ->
151
          _failure = finish_job(job.id, "failed", error_code: "worker_start_failed")
156
          Logger.warning(
157
            "work_launch_deferred job=#{job.id} worker=#{worker_name} " <>
158
              "code=#{OpenAgents.Effects.error_code(reason)}"
159
          )
160
152 161
          {:error, reason}
153 162
      end
154 163
    end
155 164
  end
156 165
166
  defp commit_job_with_launch(attributes, worker_name) do
167
    result =
168
      Repo.transaction(fn ->
169
        with {:ok, job} <- insert_job(attributes),
170
             {:ok, effect} <- enqueue_launch(job, worker_name) do
171
          %{job: job, effect: effect}
172
        else
173
          {:error, reason} -> Repo.rollback(reason)
174
        end
175
      end)
176
177
    case result do
178
      {:ok, admitted} -> {:ok, admitted}
179
      {:error, reason} -> {:error, reason}
180
    end
181
  end
182
183
  defp enqueue_launch(%Job{id: job_id}, worker_name) do
184
    OpenAgents.Effects.enqueue("work.launch_worker", %{
185
      payload: %{"job_id" => job_id, "worker" => worker_name},
186
      source_kind: "work_job",
187
      source_id: job_id
188
    })
189
  end
190
191
  @doc """
192
  Start a job's worker, reporting an already-running singleton as success.
193
194
  Public because the effect outbox's `OpenAgents.Effects.Handlers.WorkLaunch`
195
  calls it to deliver a launch this node did not complete inline.
196
  """
197
  @spec ensure_worker(module(), String.t()) :: {:ok, pid() | :ignore} | {:error, term()}
198
  def ensure_worker(server, job_id) when is_atom(server) and is_binary(job_id),
199
    do: start_worker(server, job_id)
200
157 201
  # Start a job's worker as a cluster-wide singleton under Horde. Horde routes
158 202
  # the child to whichever member `choose_node` picks and relocates it to a
159 203
  # survivor if that node dies. `{:already_started, pid}` is success: the

@@ -174,12 +218,7 @@ defmodule OpenAgents.Work do

174 218
175 219
  @doc false
176 220
  def create_job(attributes) when is_map(attributes) do
177
    result =
178
      %Job{}
179
      |> Job.create_changeset(attributes)
180
      |> Repo.insert()
181
182
    case result do
221
    case insert_job(attributes) do
183 222
      {:ok, job} ->
184 223
        broadcast_job(job)
185 224
        {:ok, job}

@@ -189,6 +228,15 @@ defmodule OpenAgents.Work do

189 228
    end
190 229
  end
191 230
231
  # The insert alone. `admit_and_launch/2` needs it without the broadcast,
232
  # because a broadcast inside the admitting transaction would announce a job
233
  # a rollback could still take away.
234
  defp insert_job(attributes) do
235
    %Job{}
236
    |> Job.create_changeset(attributes)
237
    |> Repo.insert()
238
  end
239
192 240
  def get_job!(job_id), do: Repo.get!(Job, job_id)
193 241
194 242
  def get_job(job_id), do: Repo.get(Job, job_id)
priv/migration_lineages/prior-2026-08-19.json modified +3 -1

@@ -290,7 +290,9 @@

290 290
    20260824040140,
291 291
    20260824042729,
292 292
    20260824043735,
293
    20260824184030
293
    20260824184030,
294
    20260824203139,
295
    20260824204740
294 296
  ],
295 297
  "required_tables": [
296 298
    "users",
priv/repo/migrations/20260824204740_create_effects.exs added +136

@@ -0,0 +1,136 @@

1
defmodule OpenAgents.Repo.Migrations.CreateEffects do
2
  @moduledoc """
3
  The durable effect outbox (issue #202, EFFECT-001).
4
5
  Today an intent commits and its effect fires afterwards, from the same
6
  process, on the same node: `OpenAgents.Work.start_job/1` inserts the job row
7
  and then asks Horde for a worker. A crash in the gap between those two lines
8
  leaves a committed `queued` job that nothing is executing, and nothing
9
  notices until that node boots again. That is the failure class T3 Code's
10
  teardown names as "best-effort live reactor" loss of committed work
11
  (`docs/2026-08-24-coder-first-cloud-complements.md` section 3).
12
13
  This table closes the gap. The effect row is inserted in the same transaction
14
  as the intent it belongs to, so either both exist or neither does. After the
15
  commit any worker on any node may claim it under a lease, dispatch it, and
16
  record the outcome. The inline launch stays as a fast path; the outbox is
17
  what makes it safe for the fast path to fail.
18
19
  The column set is borrowed from `deployment_runs`
20
  (`priv/repo/migrations/20260823070000_create_deployment_control_plane.exs`),
21
  the only other durable execution record here with a lease: the lease owner
22
  and its expiry, the attempt counter, the last error, and the
23
  conditional-update claim those support. What is added is the outbox half —
24
  the source that asked for
25
  the effect, the payload digest that fingerprints what was asked for, and the
26
  deterministic idempotency key that makes a redelivery safe.
27
28
  Two digests, because they answer different questions. `payload_digest`
29
  fingerprints the content, so a reused key carrying different content is
30
  refused instead of silently answered with the first result. `idempotency_key`
31
  identifies the effect, so the same intent enqueued twice produces one row and
32
  one delivery.
33
  """
34
35
  use Ecto.Migration
36
37
  def change do
38
    create table(:effects, primary_key: false) do
39
      add :id, :binary_id, primary_key: true
40
41
      # What to do, and with what. The kind selects a handler; the payload is
42
      # the handler's whole input, because a handler that reads anything else
43
      # is not replayable.
44
      add :kind, :string, null: false
45
      add :payload, :map, null: false
46
      add :payload_digest, :string, null: false
47
48
      # Who asked. `source_kind` and `source_id` name the committed intent;
49
      # `source_sequence` is its transcript position where the source has one.
50
      # A sequence is a position, never an execution claim (EFFECT-002), so it
51
      # is recorded beside the status rather than used as one.
52
      add :source_kind, :string, null: false
53
      add :source_id, :string, null: false
54
      add :source_sequence, :bigint
55
56
      add :idempotency_key, :string, null: false
57
58
      add :status, :string, null: false, default: "pending"
59
      add :attempts, :integer, null: false, default: 0
60
      add :maximum_attempts, :integer, null: false, default: 5
61
62
      # When the effect may next be claimed. Backoff moves this forward; it is
63
      # never used to express readiness any other way.
64
      add :available_at, :utc_datetime_usec, null: false
65
66
      add :lease_owner, :string
67
      add :lease_expires_at, :utc_datetime_usec
68
      add :last_error, :text
69
70
      add :claimed_at, :utc_datetime_usec
71
      add :completed_at, :utc_datetime_usec
72
73
      timestamps(type: :utc_datetime_usec)
74
    end
75
76
    # One effect per intent. The unique key is what makes `enqueue/2` safe to
77
    # call twice — from a retried request, from a replayed reactor, from a
78
    # caller that does not know whether its last transaction committed.
79
    create unique_index(:effects, [:idempotency_key])
80
81
    # The claim query in one index: pending work, oldest first.
82
    create index(:effects, [:available_at, :inserted_at],
83
             where: "status = 'pending'",
84
             name: :effects_pending_claim_index
85
           )
86
87
    # The reclaim query in one index: leases that have run out.
88
    create index(:effects, [:lease_expires_at],
89
             where: "status = 'claimed'",
90
             name: :effects_expired_lease_index
91
           )
92
93
    # "What happened to the effects this thread/job asked for" without a scan.
94
    create index(:effects, [:source_kind, :source_id])
95
96
    create constraint(:effects, :effects_status_check,
97
             check: "status IN ('pending', 'claimed', 'done', 'failed')"
98
           )
99
100
    create constraint(:effects, :effects_attempts_nonnegative_check, check: "attempts >= 0")
101
102
    create constraint(:effects, :effects_maximum_attempts_positive_check,
103
             check: "maximum_attempts >= 1"
104
           )
105
106
    create constraint(:effects, :effects_kind_present_check,
107
             check: "octet_length(kind) BETWEEN 1 AND 80"
108
           )
109
110
    create constraint(:effects, :effects_payload_present_check,
111
             check: "octet_length(payload::text) >= 2"
112
           )
113
114
    # A lease is a pair or it is nothing: an owner with no expiry never expires,
115
    # and an expiry with no owner names nobody to reclaim from.
116
    create constraint(:effects, :effects_lease_pair_check,
117
             check: """
118
             (lease_owner IS NULL AND lease_expires_at IS NULL)
119
             OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
120
             """
121
           )
122
123
    # A claimed effect holds a lease; a terminal effect holds none and carries
124
    # the time it reached that state. This is the durable half of the milestone
125
    # separation: "claimed" and "completed" cannot be the same fact.
126
    create constraint(:effects, :effects_status_shape_check,
127
             check: """
128
             (status = 'pending' AND completed_at IS NULL)
129
             OR (status = 'claimed' AND lease_owner IS NOT NULL AND claimed_at IS NOT NULL
130
                 AND completed_at IS NULL)
131
             OR (status IN ('done', 'failed') AND lease_owner IS NULL
132
                 AND completed_at IS NOT NULL)
133
             """
134
           )
135
  end
136
end
test/openagents/effects/work_launch_test.exs added +211

@@ -0,0 +1,211 @@

1
defmodule OpenAgents.Effects.WorkLaunchTest do
2
  @moduledoc """
3
  The converted call site: a work job's worker launch (EFFECT-001, issue #202).
4
5
  Before the outbox, `OpenAgents.Work.start_job/1` committed the job row and
6
  then asked Horde for a worker, from the same process on the same node. A
7
  crash in that gap left a committed `queued` job that nothing was executing,
8
  and nothing noticed: `OpenAgents.Work.recover_interrupted_jobs/0` sweeps at
9
  boot and never after, so the job sat until that node restarted.
10
11
  What these tests assert is the fix, not the happy path someone hopes for: the
12
  launch is committed with the job, so it survives the gap; a job whose inline
13
  launch never ran is still launched, by any node's worker; and delivering it
14
  twice starts one worker, not two.
15
  """
16
17
  use OpenAgents.DataCase, async: false
18
19
  alias OpenAgents.Conversations
20
  alias OpenAgents.Effects
21
  alias OpenAgents.Effects.Handlers.WorkLaunch
22
  alias OpenAgents.Effects.Worker
23
  alias OpenAgents.Work
24
  alias OpenAgents.Work.Job
25
26
  describe "admission" do
27
    test "a started job commits its launch in the same transaction" do
28
      {_conversation, job} = start_job("effect-launch-commit")
29
30
      assert [effect] = Effects.for_source("work_job", job.id)
31
      assert effect.kind == "work.launch_worker"
32
      assert effect.payload == %{"job_id" => job.id, "worker" => "job"}
33
      assert effect.source_kind == "work_job"
34
      assert effect.source_id == job.id
35
      assert effect.payload_digest =~ ~r/^sha256:[0-9a-f]{64}$/
36
37
      # The inline launch succeeded, so the ordinary path retires its own
38
      # effect: the outbox wrote one row and closed it.
39
      assert effect.status == "done"
40
      assert effect.completed_at != nil
41
    end
42
43
    test "a refused job leaves neither a job row nor a launch owed" do
44
      {:ok, conversation} = Conversations.ensure_conversation("effect-launch-refused")
45
      owner = Conversations.get_conversation_owner!(conversation)
46
      before_jobs = Repo.aggregate(Job, :count)
47
48
      assert {:error, %Ecto.Changeset{}} =
49
               Work.start_job(%{
50
                 conversation_id: conversation.id,
51
                 owner_visitor_id: owner.id,
52
                 surface: "carrier-pigeon",
53
                 goal: "an inadmissible surface"
54
               })
55
56
      # The transaction that would have written the launch never committed, so
57
      # the outbox owes nothing for an intent that was refused.
58
      assert Repo.aggregate(Job, :count) == before_jobs
59
      assert Effects.counts() == %{}
60
    end
61
62
    test "each kind names the worker its launch effect must start" do
63
      assert WorkLaunch.worker_names() == ~w(continual_learning delegation job scv)
64
      assert {:ok, OpenAgents.Work.JobServer} = WorkLaunch.worker("job")
65
      assert {:error, :unknown_worker} = WorkLaunch.worker("Elixir.System")
66
    end
67
  end
68
69
  describe "delivery" do
70
    test "a launch the inline attempt never made is delivered by the outbox" do
71
      {job, stranded} = queued_job_owed_a_launch("effect-launch-stranded")
72
      assert stranded.status == "pending"
73
74
      recording_launch_handler()
75
76
      assert %{claimed: 1, completed: 1} = Worker.run_once(identity: "worker-outbox")
77
78
      assert_received {:launch_requested, server, job_id}
79
      assert server == OpenAgents.Work.JobServer
80
      assert job_id == job.id
81
      assert Effects.get(stranded.id).status == "done"
82
    end
83
84
    test "a redelivered launch starts one worker, not two" do
85
      {job, _stranded} = queued_job_owed_a_launch("effect-launch-redelivered")
86
87
      recording_launch_handler()
88
89
      assert %{completed: 1} = Worker.run_once(identity: "worker-a")
90
      assert_received {:launch_requested, _server, delivered_job_id}
91
      assert delivered_job_id == job.id
92
93
      # The effect is done, so a second pass has nothing to deliver. The
94
      # singleton guarantee behind `ensure_worker/2` is the second line of
95
      # defence, not the first.
96
      assert %{claimed: 0, completed: 0} = Worker.run_once(identity: "worker-b")
97
      refute_received {:launch_requested, _other_server, _other_job}
98
    end
99
100
    test "a job that finished before its launch was delivered needs no worker" do
101
      {job, stranded} = queued_job_owed_a_launch("effect-launch-terminal")
102
103
      {:ok, _finished} = Work.finish_job(job.id, "cancelled", error_code: "cancelled")
104
105
      recording_launch_handler()
106
107
      assert %{claimed: 1, completed: 1} = Worker.run_once(identity: "worker-a")
108
109
      # Nothing owed is not a failure to retry: the effect completes and no
110
      # worker is asked for.
111
      refute_received {:launch_requested, _server, _job_id}
112
      assert Effects.get(stranded.id).status == "done"
113
    end
114
115
    test "a launch for a job that no longer exists completes rather than retrying forever" do
116
      {:ok, effect} =
117
        Effects.enqueue("work.launch_worker", %{
118
          payload: %{"job_id" => Ecto.UUID.generate(), "worker" => "job"},
119
          source_kind: "work_job",
120
          source_id: Ecto.UUID.generate()
121
        })
122
123
      assert %{claimed: 1, completed: 1} = Worker.run_once(identity: "worker-a")
124
      assert Effects.get(effect.id).status == "done"
125
    end
126
127
    test "a launch payload naming no worker is refused, not run" do
128
      {:ok, effect} =
129
        Effects.enqueue("work.launch_worker", %{
130
          payload: %{"job_id" => Ecto.UUID.generate()},
131
          source_kind: "work_job",
132
          source_id: "malformed"
133
        })
134
135
      assert [claimed] = Effects.claim_batch("worker-a")
136
      assert :error = Worker.dispatch(claimed)
137
      assert Effects.get(effect.id).last_error =~ "invalid_payload"
138
    end
139
  end
140
141
  # The ordinary path, run to a terminal job so no worker outlives the test.
142
  defp start_job(browser_key) do
143
    {:ok, conversation} = Conversations.ensure_conversation(browser_key)
144
    owner = Conversations.get_conversation_owner!(conversation)
145
    :ok = Work.subscribe(conversation.id)
146
147
    {:ok, job} =
148
      Work.start_job(%{
149
        conversation_id: conversation.id,
150
        owner_visitor_id: owner.id,
151
        surface: "text",
152
        goal: "just summarize the plan"
153
      })
154
155
    await_terminal(job.id)
156
    {conversation, job}
157
  end
158
159
  defp await_terminal(job_id) do
160
    receive do
161
      {:work_job_updated, %Job{id: ^job_id, status: status}}
162
      when status in ["completed", "failed", "interrupted", "budget_exhausted", "cancelled"] ->
163
        :ok
164
165
      {:work_job_updated, %Job{id: ^job_id}} ->
166
        await_terminal(job_id)
167
    after
168
      10_000 -> flunk("the started job never reached a terminal status")
169
    end
170
  end
171
172
  # The state a crash between commit and launch leaves behind: a committed
173
  # `queued` job row and, committed with it, the launch nobody ran. The shape
174
  # of the effect is not invented here — the admission test above asserts that
175
  # `Work.start_job/1` writes exactly this row.
176
  defp queued_job_owed_a_launch(browser_key) do
177
    {:ok, conversation} = Conversations.ensure_conversation(browser_key)
178
    owner = Conversations.get_conversation_owner!(conversation)
179
180
    {:ok, job} =
181
      Work.create_job(%{
182
        conversation_id: conversation.id,
183
        owner_visitor_id: owner.id,
184
        surface: "text",
185
        goal: "a job whose worker was never started"
186
      })
187
188
    {:ok, effect} =
189
      Effects.enqueue("work.launch_worker", %{
190
        payload: %{"job_id" => job.id, "worker" => "job"},
191
        source_kind: "work_job",
192
        source_id: job.id
193
      })
194
195
    assert job.status == "queued"
196
    {job, effect}
197
  end
198
199
  defp recording_launch_handler do
200
    Application.put_env(:openagents, :effects,
201
      handlers: %{"work.launch_worker" => OpenAgents.Effects.WorkLaunchTest.RecordingLaunch}
202
    )
203
204
    Application.put_env(:openagents, :effects_launch_observer, self())
205
206
    on_exit(fn ->
207
      Application.delete_env(:openagents, :effects)
208
      Application.delete_env(:openagents, :effects_launch_observer)
209
    end)
210
  end
211
end
test/openagents/effects_test.exs added +376

@@ -0,0 +1,376 @@

1
defmodule OpenAgents.EffectsTest do
2
  @moduledoc """
3
  What the durable effect outbox promises (EFFECT-001, EFFECT-002, issue #202).
4
5
  The claim under test is not "effects usually run". It is that an effect
6
  exists exactly when the intent that asked for it committed, that one worker
7
  runs it at a time, that a worker that dies holding it loses nothing, and that
8
  a redelivery is safe. Each of those is a separate failure the outbox exists
9
  to remove, so each gets its own test.
10
  """
11
12
  use OpenAgents.DataCase, async: false
13
14
  alias OpenAgents.Effects
15
  alias OpenAgents.Effects.Effect
16
  alias OpenAgents.Effects.Worker
17
18
  setup do
19
    Application.put_env(:openagents, :effects,
20
      handlers: %{"test.echo" => OpenAgents.EffectsEchoHandler},
21
      backoff_base_ms: 1_000,
22
      backoff_ceiling_ms: 300_000,
23
      lease_seconds: 120
24
    )
25
26
    Application.put_env(:openagents, :effects_test_observer, self())
27
28
    on_exit(fn ->
29
      Application.delete_env(:openagents, :effects)
30
      Application.delete_env(:openagents, :effects_test_observer)
31
    end)
32
33
    :ok
34
  end
35
36
  describe "enqueue/2 inside the caller's transaction" do
37
    test "a committed transaction leaves exactly one effect" do
38
      {:ok, effect} =
39
        Repo.transaction(fn ->
40
          {:ok, effect} = enqueue("commit-me")
41
          effect
42
        end)
43
44
      assert %Effect{status: "pending", attempts: 0} = Effects.get(effect.id)
45
      assert Effects.counts() == %{"pending" => 1}
46
    end
47
48
    test "a rolled-back transaction leaves no effect at all" do
49
      key = Effects.idempotency_key("test.echo", "test_source", "rollback-me")
50
51
      assert {:error, :intent_refused} =
52
               Repo.transaction(fn ->
53
                 {:ok, _effect} = enqueue("rollback-me")
54
                 Repo.rollback(:intent_refused)
55
               end)
56
57
      # This is the whole point of enqueuing inside the caller's transaction:
58
      # an intent that did not happen owes nothing, and nothing is delivered.
59
      assert Effects.get_by_key(key) == nil
60
      assert Effects.counts() == %{}
61
    end
62
63
    test "the same intent enqueued twice is one effect and one delivery" do
64
      {:ok, first} = enqueue("twice")
65
      {:ok, second} = enqueue("twice")
66
67
      assert first.id == second.id
68
      assert Repo.aggregate(Effect, :count) == 1
69
    end
70
71
    test "a reused key carrying different content is refused, not silently answered" do
72
      {:ok, first} = enqueue("fingerprinted", %{"body" => "original"})
73
74
      assert {:error, :payload_conflict} =
75
               Effects.enqueue("test.echo", %{
76
                 payload: %{"body" => "substituted"},
77
                 source_kind: "test_source",
78
                 source_id: "fingerprinted"
79
               })
80
81
      # The first caller's effect stands; the second caller is told no rather
82
      # than handed a result for a payload it never sent.
83
      assert Effects.get(first.id).payload == %{"body" => "original"}
84
      assert Repo.aggregate(Effect, :count) == 1
85
    end
86
87
    test "the deterministic key does not depend on the payload" do
88
      key = Effects.idempotency_key("test.echo", "test_source", "stable", 7)
89
90
      assert key == Effects.idempotency_key("test.echo", "test_source", "stable", 7)
91
      refute key == Effects.idempotency_key("test.echo", "test_source", "stable")
92
      refute key == Effects.idempotency_key("test.other", "test_source", "stable", 7)
93
    end
94
95
    test "a source sequence is recorded as evidence, never as a status" do
96
      {:ok, effect} =
97
        Effects.enqueue("test.echo", %{
98
          payload: %{"body" => "sequenced"},
99
          source_kind: "thread_event",
100
          source_id: "thread-1",
101
          source_sequence: 42
102
        })
103
104
      # EFFECT-002: a transcript position is not an execution claim and not a
105
      # completion claim. The sequence is stored; the status is separate.
106
      assert effect.source_sequence == 42
107
      assert effect.status == "pending"
108
      assert effect.claimed_at == nil
109
      assert effect.completed_at == nil
110
    end
111
  end
112
113
  describe "claim_batch/2" do
114
    test "a claim takes a lease and counts an attempt" do
115
      {:ok, effect} = enqueue("claim-me")
116
117
      assert [claimed] = Effects.claim_batch("worker-a")
118
      assert claimed.id == effect.id
119
      assert claimed.status == "claimed"
120
      assert claimed.attempts == 1
121
      assert claimed.lease_owner == "worker-a"
122
      assert DateTime.compare(claimed.lease_expires_at, DateTime.utc_now()) == :gt
123
124
      # Claiming is not completing (EFFECT-002).
125
      assert claimed.claimed_at != nil
126
      assert claimed.completed_at == nil
127
    end
128
129
    test "an effect a worker holds is not offered to the next worker" do
130
      {:ok, _effect} = enqueue("held")
131
132
      assert [_claimed] = Effects.claim_batch("worker-a")
133
      assert Effects.claim_batch("worker-b") == []
134
    end
135
136
    test "an effect whose time has not come is not claimable" do
137
      later = DateTime.add(DateTime.utc_now(), 60, :second)
138
139
      {:ok, _effect} =
140
        Effects.enqueue("test.echo", enqueue_attributes("later", available_at: later))
141
142
      assert Effects.claim_batch("worker-a") == []
143
      assert [_claimed] = Effects.claim_batch("worker-a", now: DateTime.add(later, 1, :second))
144
    end
145
146
    test "concurrent workers over one batch claim disjoint sets and never the same effect twice" do
147
      for index <- 1..12, do: {:ok, _effect} = enqueue("racer-#{index}")
148
149
      claims =
150
        ["worker-a", "worker-b", "worker-c"]
151
        |> Task.async_stream(
152
          fn worker -> Effects.claim_batch(worker, limit: 12) end,
153
          max_concurrency: 3,
154
          ordered: false,
155
          timeout: :infinity
156
        )
157
        |> Enum.flat_map(fn {:ok, claimed} -> claimed end)
158
159
      ids = Enum.map(claims, & &1.id)
160
161
      # Every effect went to exactly one worker: no effect is missing, and no
162
      # effect was handed to two workers to run twice.
163
      assert length(ids) == 12
164
      assert length(Enum.uniq(ids)) == 12
165
      assert Enum.all?(claims, &(&1.attempts == 1))
166
      assert Effects.counts() == %{"claimed" => 12}
167
    end
168
169
    test "a claim only offers kinds this release can run" do
170
      {:ok, _known} = enqueue("known")
171
172
      {:ok, _unknown} =
173
        Effects.enqueue("test.absent", enqueue_attributes("unknown"))
174
175
      assert [claimed] = Effects.claim_batch("worker-a", kinds: ["test.echo"])
176
      assert claimed.kind == "test.echo"
177
    end
178
  end
179
180
  describe "reclaim_expired/1" do
181
    test "a dead worker's lease returns the effect to the queue" do
182
      {:ok, _effect} = enqueue("abandoned")
183
      assert [claimed] = Effects.claim_batch("worker-a", lease_seconds: 1)
184
185
      after_expiry = DateTime.add(claimed.lease_expires_at, 1, :second)
186
187
      assert Effects.reclaim_expired(now: after_expiry) == 1
188
189
      reclaimed = Effects.get(claimed.id)
190
      assert reclaimed.status == "pending"
191
      assert reclaimed.lease_owner == nil
192
      assert reclaimed.lease_expires_at == nil
193
194
      # The attempt the dead worker spent is not refunded, so a handler that
195
      # reliably kills its worker still reaches maximum_attempts and stops.
196
      assert reclaimed.attempts == 1
197
198
      assert [reclaimed_again] = Effects.claim_batch("worker-b", now: after_expiry)
199
      assert reclaimed_again.lease_owner == "worker-b"
200
      assert reclaimed_again.attempts == 2
201
    end
202
203
    test "a live lease is left alone" do
204
      {:ok, _effect} = enqueue("live")
205
      assert [claimed] = Effects.claim_batch("worker-a", lease_seconds: 600)
206
207
      assert Effects.reclaim_expired() == 0
208
      assert Effects.get(claimed.id).lease_owner == "worker-a"
209
    end
210
  end
211
212
  describe "fail/2" do
213
    test "a failure backs off, releases the lease, and is retried" do
214
      {:ok, _effect} = enqueue("flaky")
215
      assert [claimed] = Effects.claim_batch("worker-a")
216
217
      before = DateTime.utc_now()
218
      assert {:ok, failed} = Effects.fail(claimed, {:provider_unavailable, 503})
219
220
      assert failed.status == "pending"
221
      assert failed.lease_owner == nil
222
      assert failed.last_error =~ "provider_unavailable"
223
      assert failed.attempts == 1
224
225
      # Backoff is a delay, not a refusal: the effect is deliverable again once
226
      # its time comes, and not before.
227
      assert DateTime.diff(failed.available_at, before, :millisecond) >= Effects.backoff_ms(1)
228
      assert Effects.claim_batch("worker-b") == []
229
230
      later = DateTime.add(failed.available_at, 1, :second)
231
      assert [retried] = Effects.claim_batch("worker-b", now: later)
232
      assert retried.attempts == 2
233
    end
234
235
    test "backoff grows and is capped" do
236
      assert Effects.backoff_ms(1) == 1_000
237
      assert Effects.backoff_ms(2) == 2_000
238
      assert Effects.backoff_ms(3) == 4_000
239
      assert Effects.backoff_ms(40) == 300_000
240
    end
241
242
    test "an effect that exhausts its attempts stops being delivered" do
243
      {:ok, _effect} =
244
        Effects.enqueue("test.echo", enqueue_attributes("doomed", maximum_attempts: 2))
245
246
      assert [first] = Effects.claim_batch("worker-a")
247
      assert {:ok, retryable} = Effects.fail(first, :first_failure)
248
      assert retryable.status == "pending"
249
250
      later = DateTime.add(retryable.available_at, 1, :second)
251
      assert [second] = Effects.claim_batch("worker-a", now: later)
252
      assert second.attempts == 2
253
254
      assert {:ok, dead} = Effects.fail(second, :second_failure)
255
256
      # An effect that cannot be run must stop pretending it will be, so that
257
      # something else can notice it.
258
      assert dead.status == "failed"
259
      assert dead.completed_at != nil
260
      assert dead.lease_owner == nil
261
      assert Effects.claim_batch("worker-a", now: DateTime.add(later, 3_600, :second)) == []
262
    end
263
  end
264
265
  describe "complete/1" do
266
    test "completion is idempotent under redelivery" do
267
      {:ok, _effect} = enqueue("redelivered")
268
      assert [claimed] = Effects.claim_batch("worker-a")
269
270
      assert {:ok, done} = Effects.complete(claimed)
271
      assert done.status == "done"
272
      assert done.completed_at != nil
273
      assert done.lease_owner == nil
274
275
      # The second worker — the one whose lease expired mid-flight and whose
276
      # effect someone else already finished — reports success without
277
      # contradicting the record or writing a second completion.
278
      assert {:ok, again} = Effects.complete(claimed)
279
      assert again.id == done.id
280
      assert again.status == "done"
281
      assert again.completed_at == done.completed_at
282
    end
283
284
    test "completing a failed effect after the fact does not resurrect a failure" do
285
      {:ok, _effect} = enqueue("late")
286
      assert [claimed] = Effects.claim_batch("worker-a")
287
      assert {:ok, _done} = Effects.complete(claimed)
288
289
      # A stale worker reporting failure for an effect already completed does
290
      # not turn a completed effect back into pending work.
291
      assert {:ok, unchanged} = Effects.fail(claimed, :too_late)
292
      assert unchanged.status == "done"
293
    end
294
  end
295
296
  describe "the worker" do
297
    test "one pass claims, dispatches, and completes" do
298
      {:ok, effect} = enqueue("dispatch-me", %{"body" => "hello"})
299
300
      assert %{claimed: 1, completed: 1, failed: 0} = Worker.run_once(identity: "worker-a")
301
302
      assert_received {:effect_ran, "hello", key, id}
303
      assert key == effect.idempotency_key
304
      assert id == effect.id
305
      assert Effects.get(effect.id).status == "done"
306
    end
307
308
    test "a handler that raises is a retry, not a crash" do
309
      {:ok, effect} = enqueue("boom", %{"raise" => "handler exploded"})
310
311
      assert %{claimed: 1, completed: 0, failed: 1} = Worker.run_once(identity: "worker-a")
312
313
      failed = Effects.get(effect.id)
314
      assert failed.status == "pending"
315
      assert failed.last_error =~ "handler exploded"
316
      assert failed.attempts == 1
317
    end
318
319
    test "a pass reclaims expired leases before it claims" do
320
      {:ok, effect} = enqueue("stranded", %{"body" => "recovered"})
321
      assert [claimed] = Effects.claim_batch("dead-worker", lease_seconds: -1)
322
      assert claimed.status == "claimed"
323
324
      assert %{reclaimed: 1, claimed: 1, completed: 1} = Worker.run_once(identity: "worker-b")
325
326
      assert_received {:effect_ran, "recovered", _key, _id}
327
      assert Effects.get(effect.id).status == "done"
328
    end
329
330
    test "an effect whose kind has no handler fails loudly rather than vanishing" do
331
      {:ok, effect} = Effects.enqueue("test.absent", enqueue_attributes("orphan"))
332
333
      # The claim only offers admitted kinds, so an unregistered kind is never
334
      # picked up and quietly marked done.
335
      assert %{claimed: 0} = Worker.run_once(identity: "worker-a")
336
      assert Effects.get(effect.id).status == "pending"
337
338
      # Dispatched directly — as a recovery path would — it is a refusal.
339
      assert [claimed] = Effects.claim_batch("worker-a")
340
      assert :error = Worker.dispatch(claimed)
341
      assert Effects.get(effect.id).last_error =~ "unknown_kind"
342
    end
343
344
    test "a running worker drives a pass on demand, with no sleeping" do
345
      {:ok, effect} = enqueue("ticked", %{"body" => "tick"})
346
347
      worker =
348
        start_supervised!({Worker, name: :effects_test_worker, poll: false, identity: "worker-t"})
349
350
      assert %{claimed: 1, completed: 1} = Worker.tick(worker)
351
      assert_received {:effect_ran, "tick", _key, _id}
352
      assert Effects.get(effect.id).status == "done"
353
    end
354
  end
355
356
  describe "for_source/2" do
357
    test "an intent can be asked what it is owed" do
358
      {:ok, first} = enqueue("audited-1")
359
      {:ok, second} = enqueue("audited-2")
360
361
      assert Effects.for_source("test_source", "audited-1") |> Enum.map(& &1.id) == [first.id]
362
      assert Effects.for_source("test_source", "audited-2") |> Enum.map(& &1.id) == [second.id]
363
      assert Effects.for_source("test_source", "never-asked") == []
364
    end
365
  end
366
367
  defp enqueue(source_id, payload \\ %{"body" => "noop"}) do
368
    Effects.enqueue("test.echo", enqueue_attributes(source_id, payload: payload))
369
  end
370
371
  defp enqueue_attributes(source_id, extra \\ []) do
372
    [payload: %{"body" => "noop"}, source_kind: "test_source", source_id: source_id]
373
    |> Keyword.merge(extra)
374
    |> Map.new()
375
  end
376
end
test/support/effects_echo_handler.ex added +31

@@ -0,0 +1,31 @@

1
defmodule OpenAgents.EffectsEchoHandler do
2
  @moduledoc """
3
  The effect handler the outbox tests script (EFFECT-001).
4
5
  It reports the effect body and its deterministic idempotency key back to the
6
  process registered as `:effects_test_observer`, so a test can assert what a
7
  handler actually received — including that redelivery hands it the same key
8
  every time. A payload carrying `"raise"` raises instead, so a test can prove
9
  that a handler blowing up is a retry rather than a lost effect.
10
  """
11
12
  @behaviour OpenAgents.Effects.Handler
13
14
  alias OpenAgents.Effects.Effect
15
16
  @impl OpenAgents.Effects.Handler
17
  def run(%Effect{payload: %{"raise" => message}}, _idempotency_key),
18
    do: raise(RuntimeError, message)
19
20
  def run(%Effect{payload: payload} = effect, idempotency_key) do
21
    case Application.get_env(:openagents, :effects_test_observer) do
22
      pid when is_pid(pid) ->
23
        send(pid, {:effect_ran, Map.get(payload, "body"), idempotency_key, effect.id})
24
25
      _absent ->
26
        :ok
27
    end
28
29
    :ok
30
  end
31
end
test/support/effects_recording_launch.ex added +52

@@ -0,0 +1,52 @@

1
defmodule OpenAgents.Effects.WorkLaunchTest.RecordingLaunch do
2
  @moduledoc """
3
  A stand-in for `OpenAgents.Effects.Handlers.WorkLaunch` that records the
4
  launch it was asked for instead of starting a Horde singleton (EFFECT-001).
5
6
  The real handler's decisions — whether a job still exists, whether it still
7
  needs a worker, which server module its payload names — are the part under
8
  test, so this delegates all of them and only replaces the one line that would
9
  reach into the cluster.
10
  """
11
12
  @behaviour OpenAgents.Effects.Handler
13
14
  alias OpenAgents.Effects.Effect
15
  alias OpenAgents.Effects.Handlers.WorkLaunch
16
  alias OpenAgents.Work
17
  alias OpenAgents.Work.Job
18
19
  @impl OpenAgents.Effects.Handler
20
  def run(%Effect{payload: payload}, _idempotency_key) do
21
    with {:ok, job_id} <- fetch(payload, "job_id"),
22
         {:ok, name} <- fetch(payload, "worker"),
23
         {:ok, server} <- WorkLaunch.worker(name) do
24
      launch(server, job_id)
25
    end
26
  end
27
28
  defp launch(server, job_id) do
29
    case Work.get_job(job_id) do
30
      nil ->
31
        :ok
32
33
      %Job{status: status} when status not in ~w(queued running) ->
34
        :ok
35
36
      %Job{} ->
37
        case Application.get_env(:openagents, :effects_launch_observer) do
38
          pid when is_pid(pid) -> send(pid, {:launch_requested, server, job_id})
39
          _absent -> :ok
40
        end
41
42
        :ok
43
    end
44
  end
45
46
  defp fetch(payload, key) do
47
    case Map.fetch(payload, key) do
48
      {:ok, value} when is_binary(value) and value != "" -> {:ok, value}
49
      _missing -> {:error, {:invalid_payload, key}}
50
    end
51
  end
52
end

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