Skip to content

feat(pull): async fan-out join + sync edge adapter — no autonomous trigger is stranded (#2524) - #2532

Merged
vybe merged 3 commits into
devfrom
feature/2524-fanout-async-join
Sep 15, 2026
Merged

vybe merged 3 commits into
devfrom
feature/2524-fanout-async-join

Conversation

@obasilakis

@obasilakis obasilakis commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #2524. Parent: #1081 — this completes Phase 4 ("sync edge adapter + async fan-out join"). Siblings: #2513/#2391 (cron), #2526/#2523 (loops, merged).

After this, every autonomous trigger can run on the durable queue. fan_out, and then a2a + operator_response, were the last three.

Updated 2026-09-15 after the 09-09/09-10 merge-train review and a merge of origin/dev. The row lifecycle, the default wait budget and the status endpoint changed from the first version of this description — see "What changed after review" at the bottom.

What was wrong

FanOutService.execute built a dict[task_id, FanOutTaskResult] from execute_task's return values inside one asyncio.gather. A pull-claimed subtask returns nothing to collect — execute_task returns as soon as the row is on the durable queue and the turn runs later, in the agent's worker — so a collector built on the return value reads an empty result for every subtask. That is why fan_out sat in the stranded half of PULL_REACHABLE_TRIGGERS (#2048).

Unlike loops (#2523), fan-out has a genuinely blocking caller — POST /fan-out returns the aggregate — which is why Phase 4 names it separately and why this PR carries an adapter.

The change

  • Every subtask row carries fan_out_id plus the caller's own fan_out_task_id (new column), and build_aggregate() rebuilds the sync FanOutResult from those rows, in input order.
  • Rows are created at slot grant, inside the max_concurrency semaphore — never up front. A row created up front and left waiting is a hidden queue every recovery path misreads: as RUNNING it is bulk-FAILed by the bug: Cleanup service misses 'skipped' executions and slow to detect no-session failures #106 no-session sweep (and matches the watchdog orphan reconcile and the stale sweep); as QUEUED it is claimable by claim_next_queued — backlog drain and pull workers — while the service also dispatches it, i.e. a double run.
  • Sync callers wait for the (shielded) dispatch, then on sync_waiter.wait_for_fan_out_batch for rows that came back queued — Phase 4's "sync edge adapter", in the shape sync_waiter already uses for /task.
  • async_mode on FanOutRequest (and on the MCP fan_out tool) → {fan_out_id, status: "accepted", total} immediately. Poll with bug(mcp): fan_out has no gateway-timeout receipt — the third route of the #914 class, and the one that runs longest #2670's GET /api/agents/{name}/fan-out/{fan_out_id} / get_fan_out_result, which now also returns each subtask's task_id.
  • fan_out joins PULL_REACHABLE_TRIGGERS.

Decisions

1. max_concurrency keeps its meaning, and needed no branch. The semaphore stays around the execute_task call. On push that call spans the whole turn, so it paces dispatch exactly as before; under pull it returns in milliseconds, so it self-releases and real concurrency becomes the agent's worker pool. Deleting it would turn N concurrent dispatches at an agent with max_parallel_tasks=3 into CapacityFull failures.

2. The outer deadline bounds the WAIT, not the work — contract change. ⚠️ A still-open subtask (including one still waiting for a slot) now reports status="running", not failed; the batch still reports deadline_exceeded. The deadline never cancels the dispatch. After a deadline the GET is the source of truth.

3. With no caller deadline the wait covers the whole batch: ceil(N / min(max_concurrency, max_parallel_tasks)) × execution_timeout_seconds + 120s. The first version used one subtask's bound, which returned deadline_exceeded on batches the old unbounded gather completed.

The join

join_fan_out_on_terminal hangs off event_dispatch_service.spawn_task_terminal_event — the wrapper every CAS-won terminal writer already calls — beside #2523's loop advance, each under its own guard (_terminal_side_effects). Not inside emit_task_terminal_event, which returns early when no subscription matches. One PK read per fleet terminal; a (fan_out_id, status)-indexed COUNT only for fan-out rows. It fires on "no open row", which is safe because the sync waiter is registered only after every subtask has been dispatched.

_dispatch_all also calls it directly on a non-QUEUED return (the fast-fail paths write FAILED without a terminal event). _fail_subtask (a raised execute_task) is a proper terminal writer: on a won CAS it closes the activity (#1804) and emits through spawn_task_terminal_event (#1578).

a2a and operator_response

task_execution_service.dispatch_and_await_terminal: execute_task, and if it comes back QUEUED, wait for that row's terminal and rebuild the result from it. Callers: routers/a2a._run_a2a_task (JSON-RPC contract untouched) and operator_resume_service (the ent#329 receipt is never queued). ⚠️ The pull sink does not signal that waiter, so the wake is the 5s DB poll — up to ~5s extra tail latency on an a2a call against a pilot agent (measured 4.36s / 0.6s in the live run below).

PULL_REACHABLE_TRIGGERS now equals _AUTONOMOUS_TRIGGERS but stays an enumerated allow-list, with a test that fails if someone derives it.

Known limits

  • The not-yet-dispatched tail is in-process. A GET mid-batch sees only the rows dispatched so far (bug(mcp): fan_out has no gateway-timeout receipt — the third route of the #914 class, and the one that runs longest #2670's receipt already treats execution_ids as evidence, not a manifest), and a backend restart loses the undispatched tail — for async_mode exactly as for a sync call. Under pull every row exists within milliseconds.
  • error_code exists only on push resultsschedule_executions has no column for it, so pull subtasks and GET results carry none.
  • sync_waiter's registry is in-process; multi-worker, the 5s DB poll is the wake path.

Migration

0062_execution_fan_out_task_id (off 0061_execution_open_canvas) + the SQLite twin. One column plus idx_executions_fan_out_status ON schedule_executions(fan_out_id, status). check_alembic_heads.py: 63 revisions, 1 head.

Follow-up that gets worse from here

#2392. effect_guard still fails open when the execution id is absent, and a fan-out multiplies that by N. Do not run a side-effect-bearing fan-out pilot before it is resolved.

What changed after review (2026-09-15)

Merged origin/dev (conflicts with #2679/#2670 in fan_out_service.py, routers/fan_out.py, executions.py, pull_pilot.py, fan-out.md).

Review finding Resolution
❌ Pre-created RUNNING rows bulk-FAILed by the #106 sweep Rows created at slot grant. QUEUED (suggested) was not used: claim_next_queued has no trigger filter, so backlog drain / pull workers would claim the rows while _dispatch_all also dispatches them. Restamp-at-grant would not protect rows while they wait.
❌ Default budget ignores ceil(N / c) Budget covers every wave (decision 3).
SUB-004 subscription_id missing Snapshotted per batch, written on each row and passed to execute_task.
error_code always null Carried from push results into the sync aggregate; limit documented.
_fail_subtask ignores CAS, never emits Side effects gated on the CAS; emits via spawn_task_terminal_event.
Docstring vs started_at ordering build_aggregate always uses input order; the read documents that started_at order is not a contract.
async_mode doesn't survive restart Documented as a known limit (above, feature flow, requirements).
_poll_db exceptions escape A failed poll read is logged and polling continues.
Docs / MCP / stale comments architecture/execution.md, architecture/api-endpoints.md, requirements/scheduling.md §37.4; MCP fan_out description corrected + async_mode; models.py / test_2048 comments.
No real-schema SQL test test_2524_fanout_real_schema.py (column, agent-scoped read, count_fan_out_open, the sweep hazard).
Duplicate status route vs #2670 This PR's route, get_status and batch_belongs_to were dropped in favour of #2670's shipped GET (unchanged contract, plus additive task_id).
Alembic fork Re-parented and renumbered to 0062.

Tests

  • tests/unit/test_2524_fanout_async_join.py — join, rows at slot grant, queued path, sync wait on queued rows, max_concurrency pacing, error_code, deadline reports the tail running, _fail_subtask CAS gating, wait budget, sync edge adapter.
  • tests/unit/test_2524_fanout_real_schema.py — the SQL against the real schema.
  • tests/unit/test_inter_agent_timeout_unit.py, test_2670_fan_out_receipt.py, test_2048_pull_pilot_reach.py, test_ent329_operator_resume.py, test_157_a2a_inbound_server.py, test_736_a2a_outbound_call.py — updated / still green.
  • MCP server: tsc --noEmit clean, npm test green.

Live-fleet evidence from the first version (pull + push A/B, lease re-delivery and poison-park) is in the comments; the row-lifecycle change above has not been re-run live.

🤖 Generated with Claude Code

@vybe
vybe deleted the branch dev September 4, 2026 13:42
@vybe vybe closed this Sep 4, 2026
@obasilakis

Copy link
Copy Markdown
Contributor Author

Verified on a live local stack with the real aeroponics fleet

Not a fixture run — aero-scout, claim-verifier and cornelius doing actual research work against a real subscription. Backend + scheduler bind-mounted from this branch, PG migrated to head 0051, all three agents recreated as PULL_MODE_PILOT_AGENTS.

25 executions, 4 autonomous triggers, ~$7.79 of real turns. Zero pushed, zero re-deliveries, zero stuck rows.

agent    : pulled=10  pushed=0  redeliv=0  $3.02
fan_out  : pulled=11  pushed=0  redeliv=0  $1.28
loop     : pulled=3   pushed=0  redeliv=0  $2.79
schedule : pulled=1   pushed=0  redeliv=0  $0.70
open/stuck rows: 0        fan-out rows with fan_out_task_id: 11/11

Loops (#2523) — three full cycles

LOOP  status=completed  stop_reason=max_runs_reached  runs=3/3  failed=0
      run 1 $1.137 (#w1)   run 2 $0.696 (#w2)   run 3 $0.956 (#w1)

Each cycle was terminal → CAS advance → park on next_run_at → 5s sweep claims → dispatch → worker pulls:

14:17:20  run 1 terminal            runs=0 → 1
14:17:35  park stamped              next_run_at = +15s exactly (delay_seconds=15)
14:17:44  park cleared, run 2 dispatched, claimed by #w2
14:25:22  run 2 terminal            runs=1 → 2
14:25:37  park stamped
14:25:46  park cleared, run 3 dispatched, claimed by #w1
14:33:28  run 3 terminal → completed / max_runs_reached  (no 4th park)

Runs landed on #w1, #w2, #w1 — different workers — so nothing in the backend carried the loop across iterations. Cross-iteration state was durable throughout: last_response (576 chars) on the loop row fed run 2's {{previous_response}}, and each run row carried its execution_id while still running, which is the ordering the advance depends on.

The agent did real work: three sourced aeroponic-tomato papers found, fact-checked via claim-verifier, and stored to the Cornelius KB — Komosa et al. 2020 (J. Elementology), Wang et al. 2019 (Life Sci. Space Res.), and a debunk tracing the widely-cited "35% cherry tomato yield increase" back to Chandra et al. 2014.

Cron (#2391 + the #2523 scheduler poll)

14:14:00  [Backlog]  queued execution BD4O3q1… (1/50)
14:14:00  [TaskExec] Pull pilot aero-scout: queued execution BD4O3q1…
                     (trigger=schedule) for worker claim instead of pushing (#2391)
14:14:00  routers.internal: Async task completed … status=QUEUED
14:14:08  claimed by aero-scout#w3
          polls #6…#54  "still running"   ← never read as a terminal
14:23:31  completed: status=success (polled 57 times) → succeeded
row: success  worker=#w3  cost=$0.70  redeliveries=0  err=none

Zero retries, zero bogus terminals. The same table holds a clean A/B on the identical agent and schedule shape: the 14:00 fire on pre-change code is (PUSH), the 14:14 fire on this branch is claimed by #w3.

Precision, so this isn't overclaimed: the scheduler logs still running, not still queued, because it prints every 6th poll (~60s) and the queued window was ~8s. The direct evidence the row was queued is backend-side (the two lines above); the scheduler polled straight through it and finalized on the real terminal 9.5 min later.

Fan-out (#2524)

Sync, 3 subtasks — returned in 11s, completed, 3/3, input order preserved, all pulled (#w2,#w3,#w2), fan_out_task_id persisted on every row. The join fired on the in-process fast path:

14:39:13.473  last subtask completes
14:39:13.481  [FanOut] fo_Wi04… complete — waking any sync caller
14:39:13.484  [FanOut] fo_Wi04… finished: 3/3 completed, 0 failed

11ms from last terminal to the caller's aggregate. (Single uvicorn worker here, so the Future always wins; multi-worker falls to the 5s DB poll, as documented.)

max_concurrency under pull — the decision this PR records, now measured. 6 subtasks with max_concurrency=1:

peak concurrent subtasks = 2      (max_concurrency requested = 1, worker pool = 3)
t3 on #w1 overlapped t2/t4 on #w3

The semaphore did not bound execution — it self-released as each dispatch returned QUEUED, and real concurrency became the worker pool. Exactly the documented behaviour, and the reason it needed no branch. (Peak 2 rather than 3 only because #w2 never happened to claim — poll timing, not a cap.)

⚠️ An earlier 3-task run at max_concurrency=2 also showed concurrency 2, but that is not evidence either way — a small fast batch plus worker poll timing explains it equally. Only the max_concurrency=1 run is decisive.

async_mode returned in 49ms with {"status":"accepted","total":2,"results":[]}, and an immediate status poll already showed both rows running — confirming live the ordering property otherwise only unit-tested: rows exist before the caller is answered, so a status poll can never 404 a live batch. Polled to completed 2/0.

Authorization boundary:

GET /api/agents/cornelius/fan-out/<aero-scout's batch>  → 404
GET /api/agents/aero-scout/fan-out/fo_doesnotexist      → 404

Two honest caveats

  • A schedule|failed row at 14:00:59 is my own restart, not a defect — All connection attempts failed, one second before the backend came down, on the agent's pre-existing thrice-weekly-finding cron. It is the (PUSH) half of the A/B above.
  • One operational cost of pull, measured: a queued row waits up to a worker poll interval before being claimed even with capacity free — ~10s observed on an idle agent. Not backpressure.

Not covered

operator_response (needs an operator-queue answer) and the a2a inbound path were not exercised live; both are unit-tested only. Nothing here exercised a lease expiry or re-delivery — redeliveries=0 throughout.

@obasilakis

Copy link
Copy Markdown
Contributor Author

Live-tested the last two triggers, and the un-gated paths with the allowlist EMPTY

Follow-up to the run above. Same local stack, same worktree mounts, same aeroponics fleet. This session closes the two triggers that comment listed as "not covered", and then answers the question that matters more for merging: what does this PR change on an install with no pilots?

23 executions, $2.37. Zero stuck rows, zero re-deliveries.

triggered_by      | pulled | pushed | redeliv | cost
------------------+--------+--------+---------+--------
a2a               |      2 |      1 |       0 | $0.380
fan_out           |      0 |     13 |       0 | $1.094
loop              |      0 |      8 |       0 | $0.698
operator_response |      1 |      1 |       0 | $0.200
open/stuck rows: 0

Every trigger appears on both paths on purpose — the pulled and pushed columns of the same trigger are the A/B.


1. operator_response (ent#329) — pulled

Agent opted in via PUT /api/agents/claim-verifier/operator-resume, a pending approval item created through db.create_operator_queue_item (the same sink operator_queue_service writes through), answered via POST /api/operator-queue/{id}/respond.

16:37:23.279  row created
16:37:23.285  [Backlog] queued execution hdXSeg6y5HxPyCEy2zAYqw (1/50)
16:37:23.285  [TaskExec] Pull pilot claim-verifier: queued execution … (trigger=operator_response)
                         for worker claim instead of pushing (#2391)
16:37:23.287  [TaskExec] operator_response dispatch for claim-verifier queued as …;
                         awaiting its terminal
16:37:28.008  claimed → started              (claim latency 4.7s)
16:37:31.742  terminal, success, $0.100
16:37:33     audit row written

The receipt is a real terminal, which is the whole point of routing this through dispatch_and_await_terminal:

{"queue_item_id":"727dd20…","execution_id":"hdXSeg6y5HxPyCEy2zAYqw","status":"success","error":null}

Never queued. The agent did what the framed answer asked and returned PULL_OPRESUME_OK 2026-09-04.

Idempotency (Invariant #18). Three answer attempts, one execution row:

attempt result
POST …/respond 200, dispatched
POST …/respond again 400 Cannot respond to item with status 'responded'
maybe_dispatch_resume(item, same answer) called directly, in a separate process returns None, no dispatch

schedule_executions count for triggered_by='operator_response' after all three: 1. The claim row is durable, not in-process:

scope                | idempotency_key                                | status
agent:claim-verifier | operator_resume:727dd20…:2a70cbbf746bb874a1e…  | completed

The third attempt is the one worth having — the status gate would have hidden a broken key.

2. Inbound a2a (ent#157) — pulled, both methods

a2a_exposed=1 on cornelius, caller authenticating with a user-scoped MCP key as Bearer.

message/send — 10.09s wall, artifact intact:

{"kind":"task","status":{"state":"completed"},
 "artifacts":[{"parts":[{"kind":"text","text":"PULL_A2A_SEND_OK"}]}]}

row: a2a | success | cornelius#w2 | redeliveries 0 | $0.127

message/streamworking immediately, then a single final frame:

16:39:19.9  data: {"result":{"kind":"status-update","status":{"state":"working"},"final":false}}
16:39:24    data: {"result":{…"status":{"state":"completed"},
                    "artifacts":[{"parts":[{"text":"PULL_A2A_STREAM_OK"}]}],"final":true}}

row: a2a | success | cornelius#w1 | redeliveries 0

The artifact is identical on both paths — same request, flag off (§3 below), 4.63s instead of 10.09s:

{"kind":"task","status":{"state":"completed"},
 "artifacts":[{"parts":[{"kind":"text","text":"PULL_A2A_SEND_OK"}]}]}

Tail latency, measured. The documented ~5s is a grid, not a constant — SYNC_WAITER_POLL_INTERVAL = 5.0 and the wake lands on the first poll after the terminal, so the cost is anywhere in [0, 5) depending on where the terminal falls:

wait started terminal applied caller answered tail
message/send 16:38:56.143 16:39:01.833 16:39:06.194 4.36s
message/stream 16:39:19.969 16:39:24.331 16:39:24.9 ~0.6s

Total pull overhead on the send case: 0.07s dispatch + 0.6s claim + 4.36s waiter = 5.0s over the identical push call (10.09 vs 4.63). For a turn measured in minutes that is noise, as the docstring says; for a sub-5s turn it is more than doubling, which is worth knowing before anyone points a chatty A2A client at a pilot agent.

Auth boundary held: unauthenticated POST /a2a/cornelius → 401, non-exposed agent → uniform 404, public card on the exposed agent → 200.


3. Flag-OFF parity — PULL_MODE_PILOT_AGENTS=""

Backend recreated with an empty allowlist and all three agents recreated so TRINITY_PULL_MODE was cleared (verified empty in each container). Every row below has claimed_by_worker IS NULL; not one queued execution or Pull pilot line appeared in the backend log for the whole window.

Loops run all iterations and finalize correctly. Three loops, 8 runs:

loop_wBTC6RmD  max_runs=3 delay=15s   → completed / max_runs_reached   3/3
loop_W2FS25fI  max_runs=3 delay=30s   → completed / max_runs_reached   3/3   (restarted mid-loop, below)
loop_35yd6QQV  max_runs=5 stop_signal → completed / stop_signal_matched 1/5

Cross-iteration state survives on the push path too — the runs returned PUSH_LOOP_RUN 1, 2, 3, each incrementing the counter it read out of {{previous_response}}.

delay_seconds parks and the 5s sweep dispatches, on push. next_run_at was stamped at exactly +15s each time and cleared by the sweep:

16:45:20  run 1 terminal, park stamped   next_run_at = 16:45:35.107  (+15.000s)
16:45:41  park cleared, run 2 dispatched
16:45:45  run 2 terminal, park stamped   next_run_at = 16:45:57.710  (+15.000s)
16:46:01  park cleared, run 3 dispatched
16:46:06  completed / max_runs_reached

The sweep is not flag-gated and it does the work on the push path as well. Granularity is the sweep period, as documented — clears landed 4–6s after due.

stop_signal is honoured. max_runs=5, stop_signal="HALT-NOW"; the agent emitted HALT-NOW on run 1 (it took the wrong branch of my prompt — model behaviour, not the platform) and the loop terminated at runs_completed=1 with stop_reason=stop_signal_matched. Gate works; the 1-of-5 is my prompt's fault, not the loop's.

Backend restart mid-loop → resumes, does not go interrupted. Restarted trinity-backend at 16:48:02 while loop_W2FS25fI was parked at runs=1/3, next_run_at=16:48:29:

16:48:02  docker restart trinity-backend
16:48:07  backend healthy   → loop still `running`, runs=1/3, park intact
16:48:33  park cleared by the sweep on the fresh backend, run 2 dispatched
16:49:19  completed / max_runs_reached  3/3

Run 2 returned RESTART_LOOP_RUN 2 — it read run 1's response out of the durable last_response across the restart. SELECT status, count(*) FROM agent_loops afterwards: zero interrupted rows. This is the intended behaviour change from dropping mark_orphan_loops_interrupted; flagging it, not fixing it. Worth a release note — an operator who restarts a backend today gets loops marked interrupted, and after this they get loops that quietly carry on.

max_concurrency does bound the push path — but not measurable the way the pull run measured it. See §4; the short version is the A/B:

max_concurrency=1, 5 subtasks max_concurrency=5, 5 subtasks
batch wall time 19.33s 4.58s
completions at 33.31 / 36.61 / 39.90 / 44.00 / 48.18 31.22 / 31.39 / 31.71
spread 3.3–4.2s apart, one at a time all 3 within 0.49s
outcome 5/5 success 3/5 success, 2 failed: Agent at capacity (3/3 parallel tasks running)

At c=1 execution is strictly serial. At c=5 against the agent's own max_parallel_tasks=3, three ran genuinely concurrently and the excess two were rejected, not queued — which is the dispatch-routing half of flag-OFF parity proved directly: overflow_policy stayed "reject".

Fan-out deadline reports running, not failed — confirmed un-gated. 3 subtasks, max_concurrency=1, timeout_seconds=10:

returned at 10.05s   batch_status = deadline_exceeded
                     d1 completed   d2 completed   d3 running        ← not `failed`
status endpoint immediately after:  running   [d1 completed, d2 completed, d3 running]
… d3 kept running and landed success on its row …
status endpoint after terminals:    completed [d1 completed, d2 completed, d3 completed]

Exactly the documented contract, and it happens with an empty allowlist. Any existing caller that branches on a subtask being failed at the deadline changes behaviour on upgrade — this is the one item in the un-gated list I'd call out loudest in the release notes.

operator_response on push, for parity — same table, one row each:

id                      status   claimed_by_worker     response
ZoEUzhEblX_D0o-kInShag  success  (null)                PUSH_OPRESUME_OK
hdXSeg6y5HxPyCEy2zAYqw  success  claim-verifier#w2     PULL_OPRESUME_OK 2026-09-04

Both audit rows record "status": "success".


4. Instrument correction — the fan-out concurrency query means different things on the two paths

The max_concurrency=1 measurement above cannot be done with the row-interval sweep the earlier comment used, and I want that on the record because it qualifies the "peak concurrency 2" number in it.

db/schedules/executions.py says started_at is written at admission, and the backlog path resets it when the row leaves the queue ("reset started_at so drain records a clean run window"). So:

  • pushstarted_at is stamped when the row is created, before the fan-out semaphore. All 5 subtasks of the c=1 batch carry started_at within 17ms of each other, and an interval sweep over them returns peak_concurrency = 5 for a batch that was provably serial. The query reads N for any N-task batch regardless of max_concurrency.
  • pullstarted_at is the claim, so the sweep measures real execution. Confirmed in this session's own rows: operator_response was created at 16:37:23.279 and carries started_at = 16:37:28.008, i.e. after the claim.

So peak=2 under pull and any push number are not the same measurement and shouldn't be compared. On push the signal is the completion cadence plus the row span, which is unambiguous here — c=1 spans of 4.38 / 7.67 / 10.96 / 15.05 / 19.23s (each row's span absorbing its own wait behind the semaphore) against c=5 spans of 4.06 / 4.23 / 4.54s.

None of this changes the PR's conclusion about max_concurrency self-releasing under pull. It changes how the next person should measure it.


5. ⚠️ The Alembic chain no longer applies to dev — this branch needs a renumber on rebase

#2526 is already merged (2bebbf58 on dev), and it landed with its revision renamed, because 0050 had been taken by 0050_agent_canvases in the meantime:

dev:     0049_execution_turn_integrity → 0050_agent_canvases → 0051_agent_loops_terminal_driven   (head)
#2532:   0049_execution_turn_integrity → 0050_agent_loops_terminal_driven → 0051_execution_fan_out_task_id

0051_execution_fan_out_task_id still carries down_revision = "0050_agent_loops_terminal_driven" — an id that does not exist on dev — and its own number now collides with dev's loops revision. Rebasing without touching it gives a dangling down_revision, and the failure is at backend boot, not at review:

alembic.util.exc.CommandError: Can't locate revision identified by '0051_execution_fan_out_task_id'

(That is a real line from this session — I hit the same class of error when I pointed the base checkout at a DB stamped on this branch's chain.) The rebase needs:

# 0052_execution_fan_out_task_id.py
revision      = "0052_execution_fan_out_task_id"
down_revision = "0051_agent_loops_terminal_driven"

Worth stating plainly for whoever merges: the loop rewrite is not pending any more, it is live on dev today, so §3's flag-OFF findings — loops surviving restart instead of going interrupted, park granularity becoming the sweep period, the extra indexed read per terminal — already describe dev, not a hypothetical.


Verification

tests/unit/test_2524_fanout_async_join.py
tests/unit/test_2523_loops_terminal_driven.py
tests/unit/test_2048_pull_pilot_reach.py
tests/unit/test_ent329_operator_resume.py
tests/unit/test_157_a2a_inbound_server.py     → 142 passed
tests/lint_sys_modules.py                     → OK (140 violations / 206 baseline, none new)
tests/lint_root_test_placement.py             → OK

Caveats

  • The two operator-queue items were created through db.create_operator_queue_item rather than by an agent writing its own ~/.trinity/operator-queue.json. That is the sink operator_queue_service writes through, and the respond→dispatch path under test starts after ingestion either way — but it is not the agent-authored seam end to end.
  • One loop failed before I could use it: I recreated the agents and started a loop seconds later, and iteration 1 hit Agent unreachable — transport circuit breaker open. The breakers were closed again a minute later and the retry was clean. Environmental, not a defect — but note on_failure="abort" did the right thing and killed the loop on the first failure.
  • Still nothing here exercises lease expiry or re-delivery — redelivery_count = 0 on all 23 rows.

…igger is stranded (#2524)

Completes #1081 Phase 4. After this, every autonomous trigger can run on the
durable queue; only the interactive ones stay on push, which is the deliberate
Open Question 7 scope cut (#1982/#1989).

FAN-OUT. `FanOutService.execute` built a `dict[task_id, FanOutTaskResult]`
inside one `asyncio.gather`, so the batch existed only while the request that
started it did. A pull-claimed subtask returns nothing to collect (the row is
queued and the turn runs later in the agent's worker), and nothing could answer
about a batch afterwards — no `async_mode`, no status endpoint, a disconnect
lost it. The batch now lives on `schedule_executions`: every subtask row carries
`fan_out_id` plus the caller's own `fan_out_task_id` (new column — the id used
to be a dict key no async batch could reach), and `build_aggregate` rebuilds the
result from the rows. Adds `async_mode` and
`GET /api/agents/{name}/fan-out/{fan_out_id}`, which also checks the batch
belongs to that agent: `fan_out_id` is opaque but not secret.

Two decisions the issue asked for.

`max_concurrency` keeps its meaning and needed no branch. The semaphore stays
around the `execute_task` call: on push that call spans the whole turn so it
paces dispatch as before, and under pull it returns in milliseconds so the
worker pool becomes the cap — Phase 5's "capacity becomes physical", by
construction. Deleting it, as first planned, would have fired N dispatches at an
agent whose `max_parallel_tasks` is 3 and turned the excess into CapacityFull.

The outer deadline bounds the WAIT, not the work — a contract change. A
still-open subtask now reports `running`, not `failed`; the batch still reports
`deadline_exceeded`. A queued or claimed row is not the backend's to cancel, and
on push the old cancellation was half-illusory anyway (it abandoned the HTTP
call while the agent kept running and billing the turn). After a deadline the
status endpoint is the source of truth.

A2A + OPERATOR_RESPONSE. These were deferred with "a2a cannot hand back a
receipt to poll" — true and beside the point: it does not need a receipt, it
needs to BLOCK CORRECTLY while the turn happens elsewhere, which
`sync_waiter.wait_for_sync_terminal` already did for `/task`.
`dispatch_and_await_terminal` is the adapter: `execute_task`, and on a QUEUED
return, wait for that row's terminal and rebuild from it. Nothing signals that
waiter on the pull path, so the wake is the 5s DB poll — up to ~5s of extra tail
latency on an a2a call against a pilot, deliberately not worth a second
signalling path.

`PULL_REACHABLE_TRIGGERS` now equals `_AUTONOMOUS_TRIGGERS` and stays an
enumerated allow-list on purpose: a structural test forbids deriving it, because
that would hand reach to the next autonomous trigger with nobody checking
dispatch can deliver it — precisely #2048's defect. `note_unreachable_pull_trigger`
is kept and still tested, against a synthetic narrowing.

The loop advance (#2523) and fan-out join share one `_terminal_side_effects`
shim off `spawn_task_terminal_event`, with separate guards so one raising cannot
cost the other its terminal.

Migration 0051 + the SQLite twin: `fan_out_task_id`, plus
`idx_executions_fan_out_status` — the join COUNTs non-terminal rows for one
batch on every fan-out terminal.

Refs #1081, #2048, #2391, #2523, ent#157, ent#329. #2392 gets worse from here:
`effect_guard` still fails open when the execution id is absent, and a fan-out
multiplies that by N.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tm7UEkd4G9KQZD5oeLRSa
@obasilakis obasilakis reopened this Sep 9, 2026
@obasilakis
obasilakis changed the base branch from feature/2523-loops-terminal-driven to dev September 9, 2026 12:05
@obasilakis
obasilakis force-pushed the feature/2524-fanout-async-join branch from 27df798 to be4d7ac Compare September 9, 2026 12:05
@obasilakis

Copy link
Copy Markdown
Contributor Author

Rebased onto dev, and the lease / re-delivery path is exercised live for the first time

Reopened and retargeted at dev. The close on 2026-09-04 was branch cleanup, not a review decision — GitHub auto-closed this when its base branch (feature/2523-loops-terminal-driven) was deleted a second after #2526 merged.

Rebase

Clean apart from one conflict in tests/registry.json, where both sides only appended entries — kept both, 235 entries, no duplicates.

The migration renumber flagged in the comment above was needed. dev now ends at 0058_portal_file_dismissals, and this branch's revision still chained off 0050_agent_loops_terminal_driven, which no longer exists under that name (it landed as 0051_agent_loops_terminal_driven when 0050 went to canvases). Two heads. Renumbered to 0059_execution_fan_out_task_id off 0058:

$ python3 scripts/ci/check_alembic_heads.py src/backend/migrations/versions
alembic-heads: 60 revision(s), 1 head (0059_execution_fan_out_task_id) — PASS.

db/migrations.py and docs/memory/feature-flows/fan-out.md updated to name the new revision.

Regression check — side by side against unmodified dev

Same machine, same command, ordering pinned (-p no:randomly), the five known-local-DNS-failure files excluded:

this branch      1 failed, 14505 passed, 36 skipped   11m20s
dev (bce0bbe1)   1 failed, 14484 passed, 36 skipped   11m40s

Same single failure on both: test_2582_portal_uploads.py::test_read_inbox_populates_mime_from_the_extension. It reproduces on unmodified dev standalone, so it is pre-existing — but worth someone picking up, since dev's suite is currently red on it. This branch adds 21 passing tests and breaks nothing.

(An earlier run with randomized ordering also flagged test_1081_physical_meter::test_available_floors_at_zero_over_ceiling[sqlite]. It passes standalone on both trees and does not recur with ordering pinned — an ordering flake, not a regression.)


Lease expiry, re-delivery and poison-park — measured, not assumed

Every soak window to date has reported redelivery_count = 0, and so did my 23 executions above. The mechanism the whole migration rests on had never been observed firing anywhere. It fires correctly.

Method: agent execution timeout dropped 3600 → 60s so the lease is 360s rather than an hour (get_execution_timeout + SLOT_TTL_BUFFER, and the stamp is exact to the millisecond), dispatch a task on a pull-eligible trigger, then SIGKILL the agent container the instant a worker claims it, so no terminal can ever land. Local stack, PostgreSQL, claim-verifier as pilot.

Run 1 — re-delivery recovers the work

12:02:43  claimed by claim-verifier#w2   lease stamped → 12:08:43.520   (claim + 60 + 300, exact)
12:02:50  agent SIGKILLed — no terminal can land
12:08:43  lease expires
12:12:01  reaper re-queues: SAME execution id, redelivery_count 0 → 1, worker and lease cleared
12:12:04  re-claimed by claim-verifier#w1   ← a different worker
12:12:10  success · response "LEASE_TEST_OK" · $0.0998 · redelivery_count 1

One row, one id, work completed on the second delivery. On the push path that same kill leaves a stranded row the watchdog writes off as failed up to two hours later. Reproduced identically a second time.

Run 2 — the cap, and the poison park

Same setup, but the agent is killed on every claim so re-delivery can never succeed:

12:22:45  claimed #w3, killed              redeliv=0
12:32:00  re-queued                        redeliv=1
12:32:02  claimed #w1, killed
12:42:00  re-queued                        redeliv=2
12:42:03  claimed #w1, killed
12:52:01  re-queued                        redeliv=3
12:52:03  claimed #w2, killed
13:02:00  CAP REACHED → terminal

Final row:

status            failed
claimed_by_worker (null)
lease_expires_at  (null)
redelivery_count  3
error             poison_lease: pull lease expired and re-delivery cap (3) reached — parked to operator queue

and the operator alert it raises:

type      alert          priority  high        status  pending
title     Task poison-parked (lease expired)
question  Execution WhIt6OM6PDsKpZlaCgBg9Q for agent 'claim-verifier' exceeded the re-delivery
          cap (3) after repeated worker lease expiries and was marked failed. Investigate the
          worker/task and re-trigger manually if appropriate.

Four claims, three re-deliveries, bounded exactly at the cap, terminal state honest about why, and a human is told. No infinite retry, no silent loss.

What this does and does not settle

  • Settles: the lease is stamped correctly, expiry is detected, re-delivery preserves the execution id and recovers real work, the cap bounds it, and the poison park reaches a human. All of it on the durable-queue path with a pilot agent.
  • Does not settle: every one of these re-deliveries re-ran a turn with no side effects. Re-delivery is now demonstrably a designed, working behaviour rather than an accident — which makes exactly-once coverage on the emitting side load-bearing, not theoretical. Note that the guard wraps seven sinks and email is not one of them: send_message only delivers over telegram/slack/whatsapp/web, and an agent mailing through its own provider key bypasses the guard entirely and writes no effect: row. That is the gap feat(pull): platform-injected execution id for the side-effect guard — Tier-6 T6.3, blocks default-ON for side-effect agents #2392 exists for, and it cannot be measured on the current soak pilot, which emits nothing at all.
  • Not a substitute for the soak. This is a forced fault on one local agent, not fleet traffic. It does establish that a zero in the production redelivery_count column means "no faults occurred", not "the mechanism is untested".

Everything else from the earlier comment — the two triggers exercised live, the flag-OFF parity results — is unchanged and still applies to this rebased commit.

@vybe

vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-09: not on this train. The schema lane is clean and I checked it rather than assuming: SQLite migration registered at db/migrations.py:4283, Alembic 0059_execution_fan_out_task_id parented on 0058_portal_file_dismissals which is origin/dev's current head with nothing else chaining off it, check_alembic_heads.py reports 60 revisions / 1 head both on the PR tree and on a git merge-tree against the live dev tip, and both db/schema.py:278 and db/tables.py:274 carry the column so fresh builds are right on both engines. Tests genuinely execute — all 19 drive real FanOutService.execute / _dispatch_all / build_aggregate / join_fan_out_on_terminal through a _DB double whose update_execution_status deliberately moves the row so a batch cannot complete by fake. Your three live-fleet comments are real evidence and I read them as such.

Two criticals, the first reproduced by execution.

1. Pre-created RUNNING rows behind a semaphore queue = the #106 watchdog bulk-FAILs the undispatched tail of every batch

fan_out_service.py:285-306 creates all N rows up front; db/schedules/executions.py:163-165 stamps them status=RUNNING, started_at=now, claude_session_id=NULL, lease_expires_at=NULL. Dispatch is then paced by asyncio.Semaphore(max_concurrency) (:416,422), and the claude_session_id='dispatched' sentinel is only written inside execute_task step 3.

cleanup_service._sweep_no_session_executions runs every 300s with NO_SESSION_TIMEOUT_SECONDS = 60 and matches exactly that row shape — db/schedules/cleanup.py:207-222: status=RUNNINGclaude_session_id IS NULLstarted_at < now-60slease_expires_at IS NULL.

Reproduced against real ScheduleOperations and the real schema via db_harness, 5 rows, 3 dispatched:

swept: 2
statuses: {t3:'failed', t4:'failed', t0:'running', t1:'running', t2:'running'}
open after sweep: 3
errors: ['Silent launch failure: no Claude session created within 60 seconds', ...]

Failure scenario at defaults (max_concurrency=3, agent timeout 900s): a fan_out of 12 tasks with 3-minute turns. At the first cleanup tick ≥60s in, tasks 4-12 are still behind the semaphore, so nine rows go FAILED with a fabricated launch-failure error and a fabricated duration_ms. count_fan_out_open then drops to 0 as soon as wave 1 finishes, join_fan_out_on_terminal (:228-232) signals, and the sync caller gets a "complete" aggregate reporting 9 of 12 subtasks failed. Meanwhile _dispatch_all keeps going: those nine turns do execute and bill real money, and their later SUCCESS overwrites the row — so the returned aggregate and the persisted row permanently disagree. mark_stale_executions_failed (cleanup.py:47) is the slower cousin of the same bug.

This is the #2435 class re-entering: every age check is anchored at admission, and the PR introduces a new hidden queue without the restamp_execution_dispatch mitigation (called from exactly one place today, the agent_call_limiter park at task_execution_service.py:363). The old code was immune because it created each row inside the semaphore — old run_subtask passed no execution_id.

Your live runs did not surface it because those batches finished fast enough to slip between 300s cleanup ticks. A 12-task batch of 3-minute turns will not.

Fix shape: create the rows QUEUED (both sweeps exclude it, and count_fan_out_open already treats queued as open) and let execute_task flip to RUNNING; or restamp started_at at semaphore grant. Which one is a design call, which is why this is yours rather than a train patch.

2. The default wait budget ignores ceil(N / max_concurrency)fan_out_service.py:380-394

With no caller timeout_seconds the budget is db.get_execution_timeout(agent) + 120 — the bound on one subtask — while the batch's wall clock is ceil(N/max_concurrency) × turn. The old behaviour was an unwrapped asyncio.gather, which waited as long as it took. 20 tasks at concurrency 3 with 3-minute turns is 7 waves ≈ 1260s against a 1020s budget, so the sync POST /fan-out returns deadline_exceeded with most subtasks running on a batch that previously returned complete results. The module docstring's "What did NOT change, and why" does not cover this.

Warnings worth folding into the same pass:

Verified clean, on the record: Invariant #18 holds (the POST still takes Idempotency-Key and snapshots the ACCEPTED receipt for async_mode; the new GET is a read; dispatch_and_await_terminal creates no execution of its own). Double-completion of a parent is impossible — signal_fan_out_batch guards on fut.done() + InvalidStateError and wait_for_fan_out_batch pops the registry in finally, so a child terminal arriving twice or after the join is a no-op; the batch can be woken early (critical 1) but never twice. dispatch_and_await_terminal writes no terminal on wait-timeout, so no phantom terminal and no CAS race. Route shape is Invariant #4-safe and the auth pair is enumeration-uniform.

Rides the next train once the row lifecycle and the deadline are settled.

@vybe

vybe commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-10: not on this train. All 25 checks are green and the schema lane is clean, but there is a reproduced data-integrity regression that CI structurally cannot see. Rides the next train once it's resolved.

❌ Pre-created RUNNING rows behind the semaphore are bulk-FAILed by the #106 no-session sweep

Not flag-gated — this hits any install using fan-out with N > max_concurrency (default 3, MAX_TASKS 50).

fan_out_service.py:285-306 creates all N rows before dispatch, outside asyncio.Semaphore(max_concurrency) (:416, :422). db/schedules/executions.py:163-165 stamps each status=RUNNING, started_at=now with claude_session_id and lease_expires_at NULL. The 'dispatched' sentinel is only written inside execute_task step 3, which the queued tail never reaches. db/schedules/cleanup.py:207-222 matches exactly that shape, with NO_SESSION_TIMEOUT_SECONDS = 60 on a 300s tick.

The old code was immuneorigin/dev:fan_out_service.py:127-140 calls execute_task with no execution_id, so the row was created inside the semaphore.

Reproduced against the real ScheduleOperations and real schema (5 rows, 3 dispatched):

SWEPT: 2
STATUSES: {'t3':'failed','t4':'failed','t0':'running','t1':'running','t2':'running'}
OPEN AFTER SWEEP: 3
ERRORS: ['Silent launch failure: no Claude session created within 60 seconds', ...]

The harm chain is worse than the sweep itself, and every link was read rather than inferred: count_fan_out_open hits 0 as soon as wave 1 finishes → join_fan_out_on_terminal (:228-232) signals → the sync caller receives a "completed" aggregate reporting the tail as failed. Meanwhile _dispatch_all.run_subtask (:418-421) re-checks nothing but execution_ids, and execute_task with a supplied execution_id skips past the create branch (task_execution_service.py:1071-1100) with no status guard — so those turns do run and do bill, and their later SUCCESS (unconditional CAS) overwrites the row. The returned aggregate and the persisted row disagree permanently.

This is the #2433/#2435 class re-entering: a new hidden queue with every age check still anchored at admission, and no restamp_execution_dispatch equivalent. Your live runs missed it because those batches finished between 300s ticks.

Two valid fixes, and the choice is yours because it changes what duration_ms means — create the rows QUEUED (both sweeps exclude it, and count_fan_out_open already treats it as open), or restamp started_at at semaphore grant. Same root as the duration_ms warning below.

❌ The default wait budget ignores ceil(N / max_concurrency)

fan_out_service.py:380-394: with no caller timeout_seconds the budget is get_execution_timeout(agent) + 120 — the bound on one subtask — while the batch's wall clock is ceil(N/max_concurrency) × turn. Previously (origin/dev:192-195) it was await asyncio.gather(...), i.e. unbounded. 20 tasks at concurrency 3 with 3-minute turns ≈ 1260s against a 1020s budget: the sync POST /fan-out returns deadline_exceeded with most subtasks still running, on a batch that used to return complete results. Your "What did NOT change, and why" section doesn't cover this. The right multiplier is a product call.

⚠️ Also worth fixing while you're in here

  1. SUB-004 regressionfan_out_service.py:287-299 calls db.create_task_execution directly and omits subscription_id; the resolver lives only in execute_task's if not execution_id: branch, which the pre-created row skips. db/subscriptions.py:1177/:1324 filter usage on that column, so every fan-out subtask silently drops out of the subscription usage breakdown.
  2. error_code is now always null for real failures — it's populated only on the synthesized "no execution row" branch (:191), and schedule_executions has no error_code column. The old code set result.error_code.value. Your documented "⚠️ Contract change" covers the new running status but not this.
  3. _fail_subtask doesn't gate on the CAS bool and never emits (:475-483) — safe today (_close_predicate's FAILED arm requires activity_state='started', so a lost CAS is an ALREADY_CLOSED no-op), but it's a fresh hole in the feat: system-emitted agent.task.completed/failed events at execution terminal (async caller report-back) #1578 emit set: a subtask whose dispatch raises produces no agent.task.failed.
  4. Doc/code contradictionbuild_aggregate's docstring (:158-160) says rows come back in "the DB's order — deliberately NOT started_at"; list_fan_out_executions (db/schedules/executions.py:736) is .order_by(started_at.asc()).
  5. async_mode doesn't survive a restart_dispatch_all is spawned in-process (:315); on restart the undispatched tail has no queued rows and no startup recovery, and is swept by the same path as the critical above.
  6. MCP surface stale and now actively wrong (Invariant feat: SMARTS trading pipeline with Telegram notifications and Miro visualization #13) — tools/chat.ts:599 still says tasks past the deadline are "marked as failed with timeout error" (now running, per your own contract change) and :619 says "no outer deadline is applied" (now timeout + 120). Two more stale comments sit inside your own diff hunks: models.py:2843 and test_2048_pull_pilot_reach.py:36.
  7. No test executes the new SQL against a real schema, and no test calls GET /api/agents/{name}/fan-out/{fan_out_id}list_fan_out_executions, count_fan_out_open, fan_out_task_id and the new index exist in tests/ only as methods on the _DB fake. The route's auth pairing and 404-ordering are unproven through FastAPI. To be clear, test_2524_fanout_async_join.py genuinely drives the service layer against a double whose update_execution_status actually moves the row — 24 passed — so this is a gap at the edges, not a source-text suite.
  8. Unused from datetime import datetime (:61); batch_belongs_to re-runs list_fan_out_executions so the status route issues the same query twice (routers/fan_out.py:159,162).

Verified clean, so you don't re-check it

Invariant #18 holds (the ACCEPTED receipt is the right thing to replay); Invariant #4 safe (agents_router declares no competing two-segment GET); Invariant #8 correct (get_authorized_agent properly paired with {name}, both 404 arms uniform); Invariant #1 holds — and the join correctly hangs off spawn_task_terminal_event outside the subscriber-gated emit, which is the trap it needed to avoid. No double-wake. Security §4.1-4.3, §4.5, §4.6, §4.9, §4.10: zero hits. Test deletions in test_1766/test_2048 are consequential updates, not weakening.

Schema

Both tracks present and correct, and single-head on its own. The blocker is only that #2619 is 0059_agent_canvases_pinned off the same 0058_portal_file_dismissals — the #2068 fork. Confirmed with the real guard on the combined tree:

alembic-heads: FAIL — resolves to 2 heads across 61 revision(s); exactly 1 is required.
  • 0059_agent_canvases_pinned
  • 0059_execution_fan_out_task_id
They fork at: 0058_portal_file_dismissals

#2619 also ejected this round, so neither is landing yet and the fork is unresolved either way. Your revision is applied nowhere (it doesn't exist on dev), so this is a re-parent, not a merge revision — a one-line down_revision change plus the cosmetic Revises: docstring, whenever the ordering settles. Orthogonal to the fixes above.

@vybe

vybe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-11: not on this train. The 2026-09-10 ejection (pre-created RUNNING rows behind the semaphore bulk-FAILed — a reproduced data-integrity regression) has no push after it; the branch's last commit is the 09-09 dev merge. Also noting for whenever it is re-pushed: 0059_execution_fan_out_task_id shares down_revision = 0058_portal_file_dismissals with #2619's 0059_agent_canvases_pinned (the #2068 two-heads fork); whichever of the two lands second re-parents. Rides the next train once the 09-10 finding is resolved.

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

@vybe

vybe commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-12: not on this train — conflicts with dev in three src/backend files: db/schedules/executions.py, routers/fan_out.py, services/fan_out_service.py. That's a re-apply over someone else's restructure, which needs the author's intent rather than the train's. Please merge origin/dev and push.

Also: 0059_execution_fan_out_task_id shares down_revision = 0058_portal_file_dismissals with #2728 and #2619. None of the three is on this train, so the slot is still open — but whichever lands first, the others re-parent (#2068).

Rides the next train once dev is merged.

@vybe

vybe commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-13: not on this train. The previous ejection (see the merge-train comment above) has no push after it — the branch head is unchanged — so the same finding stands. Rides the next train once addressed.

@github-actions

Copy link
Copy Markdown

🚧 Alembic head check could not run — this PR conflicts with dev.

git merge-tree reported conflicts, so there is no merged tree to check. GitHub cannot compute refs/pull/N/merge in this state either, which is why a conflicting PR shows no checks at all.

Merge dev into this branch and push. The head check re-runs automatically on the next push to dev touching src/backend/migrations/versions/**.

Advisory — this check does not block merge. · head_sha: 534e34bf1df5078871acafe01be4bd9bf9ef4b8e · run

@vybe

vybe commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

merge-train: ejected this run — rides the next train once rebased. (1) Alembic fork (#2068 class): 0059_execution_fan_out_task_id has down_revision = 0058_portal_file_dismissals, but dev already has 0059_agent_canvases_pinned off that parent (#2619). Two heads → upgrade head applies zero revisions on PostgreSQL. Re-parent to 0059_agent_canvases_pinned and renumber to 0060_…; check the SQLite migrations.py version number too. (2) Conflicts with dev in src/backend/db/schedules/executions.py, src/backend/routers/fan_out.py, src/backend/services/fan_out_service.py, src/backend/services/pull_pilot.py (plus feature-flows/fan-out.md) — a src/ re-apply that needs the author. Note #2728 carries the same fork; whichever of the two lands first, the other re-parents once more.

Resolves conflicts with #2679 (#2670) by adopting its shipped
GET /api/agents/{name}/fan-out/{fan_out_id} and dropping this branch's
duplicate route, get_status and batch_belongs_to. The GET gains an
additive task_id from fan_out_task_id.

Review fixes (#2524, merge-train 2026-09-09/10):
- Create each subtask row at slot grant inside the max_concurrency
  semaphore instead of up front. Pre-created RUNNING rows waiting behind
  the semaphore were bulk-FAILed by the #106 no-session sweep; QUEUED
  rows would be claimed by claim_next_queued while _dispatch_all also
  dispatched them. The sync caller now waits for the shielded dispatch,
  then for queued rows to reach a terminal.
- Default wait budget covers ceil(N / min(max_concurrency,
  max_parallel_tasks)) waves instead of one subtask's bound.
- Snapshot subscription_id on fan-out rows (SUB-004).
- Carry execute_task's error_code into the sync aggregate.
- _fail_subtask gates side effects on the CAS and emits through
  spawn_task_terminal_event.
- A failed fan-out DB poll read no longer escapes the wait.
- MCP fan_out: async_mode param, corrected deadline wording.
- Docs: architecture/execution.md, api-endpoints.md,
  requirements/scheduling.md 37.4, fan-out flow.
- Real-schema test for the fan-out SQL.

Renumber the Alembic revision to 0062_execution_fan_out_task_id off
0061_execution_open_canvas (single head).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: validated on #2820 (batch of 6, full suite green; pg-migrations and schema-parity green there, journey-smoke green alongside #2809's J10 fan-out journey — the interaction this batch existed to test). Lane B+schema — /validate-pr + /review. Coverage mutation-proven: 14/14 mutants caught, zero source-text assertions in either new test file. Five invariants verified: the single new terminal writer is CAS-gated and closes its #1804 activity (with the join placed outside the try, so a raising close cannot strand the batch); the Invariant #18 idempotency triple holds; zero-fire on the join is impossible by contradiction; MCP types match the backend contract with optional on both sides; none of the 8 modified existing tests weakened an assertion. Dual-track migration complete (SQLite + Alembic 0062 on 0061, single head before and after). Two post-merge follow-ups noted on the PR: build_aggregate's queued branch and the SELECT column list sit in the seam between the fake-db service tests and the real-schema db tests, where four mutation probes survived.

@vybe
vybe merged commit 7b3cf20 into dev Sep 15, 2026
30 of 31 checks passed
dolho added a commit that referenced this pull request Sep 15, 2026
… nothing — the rebuild's justification was false

Two merge-train findings on #2805:

1. dev's head moved to `0062_execution_fan_out_task_id` (#2532) after this
   branched, and this revision declared the same parent — a live #2068
   fork, two heads, `upgrade head` applying zero revisions. Re-chained as
   `0063_agent_sync_state_git_dir_bytes_bigint` off `0062_execution_fan_out_task_id`;
   `check_alembic_heads.py` on the merged tree: 64 revisions, 1 head.

2. The SQLite rename-swap rebuild was justified by "the schema-parity suite
   would go red forever" — disproved by a one-line negative control
   (registration removed, suite still green): both parity fixtures build
   from empty, so `init_schema` creates the table in both snapshots and the
   guard cannot see this column's declared type. A boot-time DROP TABLE of a
   live table for a CI benefit that does not exist is the wrong trade.
   The rebuild, its column list and its three tests are gone; a note beside
   `_migrate_agent_sync_state_git_dir_bytes` says why the SQLite track
   deliberately carries nothing, a test pins that it stays that way for a
   reason, and the learnings entry now states the honest lesson: run the
   negative control before writing a guard's behaviour into a durable file.

The seven sibling columns with the same int4-vs-`< 2**63` mismatch are
filed as #2827.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants