Skip to content

fix(execution): the CAS-won terminal owns the paired activity close (#1804) - #1863

Merged
vybe merged 13 commits into
devfrom
AndriiPasternak31/issue-1804
Jul 29, 2026
Merged

fix(execution): the CAS-won terminal owns the paired activity close (#1804)#1863
vybe merged 13 commits into
devfrom
AndriiPasternak31/issue-1804

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Fixes #1804

Problem

Every terminal writer outside the dispatching coroutine wrote the terminal to schedule_executions and walked away. The paired agent_activities row stayed activity_state='started' — the Dashboard Timeline rendered the agent as still working (ReplayTimeline.vue) until the generic 120-minute sweep closed it with a fabricated duration_ms = now − started_at that nothing ever recomputes. A 15-minute run was permanently recorded as a ~120-minute failure.

The close was gated on holding the activity_id local and winning the CAS in the same coroutine, so it failed two independent ways: process death (no finally, the id dies with the coroutine) and CAS loss (_write_terminal_and_gate had no lost-CAS branch, unlike the SUCCESS applier).

Third appearance of the class after #45 (tool-call activities) and #767 (CB probes) — both patched a single producer and left the ownership model alone.

Fix — move the responsibility to whoever wins the CAS

One owner: activity_service.close_execution_activity(...) (+ a sync spawn_close_execution_activity wrapper for the pull sink), structurally the twin of event_dispatch_service.spawn_task_terminal_event. It maps the terminal via the shared models.activity_state_for_terminal (#1332 — never a second mapping), delegates to complete_activity so the agent_activity WS broadcast survives, and is fail-open: it runs after a committed terminal and can never affect it.

The close is itself a CAS. db.complete_activity now returns ActivityCloseOutcome (UPDATED/ALREADY_CLOSED/NOT_FOUND) — one bool cannot answer both "did this row exist" (routers/internal.py 404s on it) and "did anything change" (the broadcast gate), and once idempotent no-op closes are designed behaviour the two answers diverge routinely. "The CAS winner owns it" only holds if a second closer trying is safe.

The lattice is the authority ordering, stated literally (started < failed < {completed, cancelled}): an authoritative COMPLETED accepts started|failed (so the #1083 late-SUCCESS-after-lease-expiry path may upgrade a provisional FAILED); CANCELLED/FAILED accept started only. The COMPLETED arm is deliberately tighter than the execution row's own != CANCELLED — see the review note below. The lookup is widened to agree (include_failed=True for authoritative terminals); a lookup narrower than the write would make the whole fix inert.

Split by cardinality: single-row recovery paths use the per-row helper (WS broadcast preserved); the two bulk sweeps use db.close_open_activities_for_executions (set-wise, one transaction, chunked). _close_bulk_swept_activities is a sibling of _emit_bulk_terminal_events, never folded into it — that method short-circuits on has_task_terminal_subscribers() is False, which would skip the close on every install with no event subscribers.

Wired writers: _write_terminal_and_gate (won and lost), apply_result (both branches), both backend-shutdown CancelledError handlers, watchdog + startup _recover_execution (the latter's CAS bool was previously discarded), the two bulk sweeps, the lease reaper (park → FAILED, re-queue → CANCELLED: a superseded attempt is not a failure), the pull sink, and terminate_execution.

The parity guard (test_1804_terminal_activity_parity.py) is anchored on terminal writes, not on #1578 event emission — the two backend-shutdown handlers write a terminal and emit nothing, which is exactly how they hid.

Review findings fixed in-branch

  1. The lattice inherited the upstream's permissive edge — the bug it exists to prevent. The predicate was specified as an ordering but implemented as an exclusion copied literally from the execution CAS (!= 'cancelled'), and those disagree exactly on the reflexive case: != 'cancelled' matches 'completed'. A second COMPLETED close re-dated completed_at/duration_ms/error — a 15-minute activity became ~9h, i.e. the bug: recovery paths write execution terminals without closing the paired activity — agent shows as running for up to 2h, then logs a fabricated 120-min failure #1804 symptom arriving through bug: recovery paths write execution terminals without closing the paired activity — agent shows as running for up to 2h, then logs a fabricated 120-min failure #1804's own fix, reachable from the lost-CAS branch (which passes an explicit activity_id, so the narrower lookup cannot shield it). The literal copy was faithful; the execution row survives that edge only because the refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083 callback's replay short-circuit stops the duplicate upstream — a protection the activity's call sites don't have.
  2. A CAS loss to a successful row stamped "superseded by ..." onto a clean success and rendered the enum repr (str, Enum, the feat: system-emitted agent.task.completed/failed events at execution terminal (async caller report-back) #1578 footgun). Now error=None on an authoritative SUCCESS close, .value for the label.
  3. _recover_execution returning the CAS bool changed what a False means to recover_orphaned_executions, which counted every False as errors. A lost CAS is RELIABILITY-005 working as designed, so it's now partitioned into cas_lost; genuine exceptions self-count.

Observability

CleanupReport.activities_closed_on_recovery. stale_activities should trend to ~0 while this picks up the volume — a non-zero stale_activities now means a producer is still unowned. The 120-minute backstop is demoted to a backstop for the unclaimed and moved to run after _sweep_stale_slots (it used to run one line before the reaper that legitimately closes activities, so within a single cycle the duration fabricator could beat a real closer). #429 deletes the backstop entirely — a contract survives that, per-site patches would not.

Scope note

db/schedules/cleanup.py::mark_no_session_executions_failed now returns the CAS-won count rather than the candidate count (it previously over-reported report.no_session_executions by counting rows whose guarded UPDATE lost). Its sibling mark_stale_executions_failed already did this — a deliberate, visible value change.

Verification

  • tests/unit/ full suite, 5143 passed / 14 skipped (alphabetical order and --randomly-seed=12345); 58 new tests across 3 files.
  • tests/lint_sys_modules.py: no new violations (test_reset_preserve_state_guardrails.py 9 → 0).
  • Requirements-structure guard: clean. No schema change, no migration, no feature flag.

Pre-existing flake, not from this branch: under some orderings test_1081_physical_meter.py::{test_max_respects_506_clamp,test_available_floors_at_zero_over_ceiling}[sqlite] fail with assert 5 == 2 — its set_ceiling monkeypatch doesn't reach clamp_to_ceiling. Attribution was done by replaying the literal failing order on pristine origin/dev (seeds aren't comparable across trees once the collected set changes): dev fails identically. This branch only shifts which seeds surface it. Worth a separate issue.

🤖 Generated with Claude Code

AndriiPasternak31 and others added 12 commits July 28, 2026 18:51
…1804)

Trinity Rule #1 — requirements before implementation.

scheduling.md §10.15: the terminal-write contract now includes closing the
paired agent_activities dispatch row. Records the one-owner helper, the
activity CAS lattice (mirrors the execution predicate), the widened lookup,
the bulk/per-row split by cardinality, and the terminal-write-anchored parity
guard.

infrastructure.md §12.9 (CLEANUP-001): the 120-minute activity sweep is
demoted to a backstop for the unclaimed, and moves after the stale-slot
reaper so it can no longer beat a legitimate closer within one cycle.

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

T1+T2. The db-layer spine for the #1804 contract.

- ActivityCloseOutcome (UPDATED / ALREADY_CLOSED / NOT_FOUND). One bool cannot
  answer both "did this row exist" (routers/internal.py 404s) and "did anything
  change" (activity_service broadcasts); once idempotent no-op closes are a
  designed outcome the two answers diverge routinely.
- complete_activity becomes an atomic CAS on _close_predicate, which MIRRORS the
  execution-row predicate in db/schedules/executions.py: incoming COMPLETED ->
  activity_state != 'cancelled' (an authoritative close may upgrade a
  provisional FAILED — the #1083 late-SUCCESS-after-lease-expiry path); incoming
  CANCELLED/FAILED -> activity_state = 'started' (nothing overwrites an
  authoritative close, so a double close cannot clobber completed_at /
  duration_ms / error). The lattice diagram is inline over the predicate.
- get_open_activity_id_for_execution takes include_failed so the LOOKUP agrees
  with the write — a lookup narrower than the CAS makes the widened predicate
  inert. Ordering prefers still-'started' rows.
- close_open_activities_for_executions: set-wise bulk close for the watchdog
  sweeps, one transaction, no per-row WS, chunked at _SQLITE_MAX_IN_VARS.

No schema change, no migration (Rule #9 not triggered).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e close contract (#1804)

T3. A leaf helper on activity_service (imports only models/database/db.activities
— no services.* edge, so no cycle) that every CAS-won terminal writer calls.

- Reuses models.activity_state_for_terminal (#1332) — no second mapping.
- Lattice-aware lookup: an authoritative terminal (SUCCESS/CANCELLED) searches
  started|failed so it can upgrade a provisional FAILED; a provisional terminal
  searches started only. Callers pass a terminal status and stay ignorant of it.
- Delegates to complete_activity, so the agent_activity WebSocket broadcast and
  subscriber notify survive (what a db-layer close would have lost).
- Broadcasts only on ActivityCloseOutcome.UPDATED; ALREADY_CLOSED is a designed
  no-op and must not emit an event claiming the activity just closed.
- complete_activity's bool now means "exists and closed" — only NOT_FOUND is
  False, so routers/internal.py keeps its 404 semantics and an idempotent
  re-close does not start 404ing the scheduler.
- spawn_close_execution_activity: sync fire-and-forget wrapper for the
  synchronous pull sink, mirroring spawn_task_terminal_event (strong task ref,
  fail-open with no running loop).

Fail-open throughout: the close runs after a committed terminal write.

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

T4-T8. Wires the eight terminal writers onto the single owner.

task_execution_service:
- _write_terminal_and_gate gains the missing CAS-LOSS branch (the SUCCESS
  applier has had one since #1332): re-read the row and close the activity in
  the terminal that actually stands. `activity_status` leaves the signature —
  it is derived from `status`, so the two can no longer drift.
- the asyncio.CancelledError shutdown handler now closes too. #767 closed the
  execution record here so the sweep would not inflate ITS duration but left the
  activity for the 120-minute backstop to inflate instead; worse, the row is
  `failed`, so startup recovery (which scans `running`) skips it forever.

routers/internal: the second shutdown writer, same shape (no activity_id in
scope — the helper looks it up).

cleanup_service:
- watchdog and startup _recover_execution close on the CAS-won branch. The
  startup one was DISCARDING its CAS bool (returned True unconditionally absent
  an exception); it is now captured, gates the close, and is returned.
- _close_bulk_swept_activities: a SIBLING of _emit_bulk_terminal_events, never
  folded into it — that method short-circuits when nobody subscribes, which
  would skip the close on every install with no event subscribers. Driven by the
  collect_failed rows #1714 already collects; one transaction, no per-row WS.
- _sweep_stale_activities moves AFTER _sweep_stale_slots: it ran one line before
  the reaper that legitimately closes activities, so within a single cycle the
  120-minute fabricator could beat a real closer.
- _close_reaped_activity / _close_stale_slot_activity delegate to the helper.
- CleanupReport.activities_closed_on_recovery (not summed into `total` — it is
  an observability counter over work already counted).

pull_coordination_service: closes on the CAS-won branch via the sync spawn
wrapper. lease_reaper_service: requeued_execution_ids, closed CANCELLED — a
superseded attempt is not a failure, and the re-queue preserves execution_id so
the next delivery opens a second activity against the same row.

chat_execution_service: terminate folds onto the helper (it was the fifth
hand-rolled copy of the idiom, and a copy is invisible to the parity guard).
Behaviour unchanged — R4.

db/schedules/cleanup: mark_no_session_executions_failed returns the CAS-WON
count, not the candidate count (its sibling already did). report.
no_session_executions was over-reporting; deliberate, visible value change.

Tests updated where a mock encoded the old call surface; one behavioural
assertion inverted on purpose (test_terminal_write_cas_gate: a lost CAS now
closes the activity in the persisted state instead of leaving it open).

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

T9+T10.

test_1804_recovery_closes_activity.py — db layer against the real schema
(db_harness) + service + every wired writer:
  R1 a FAILED activity upgrades to COMPLETED, through the lookup (the pairing
     that keeps the fix from being inert)
  R3 an already-closed close never touches completed_at/duration_ms/error
  R2 _write_terminal_and_gate's CAS-loss closes with the PERSISTED state
  R5 both backend-shutdown CancelledError handlers close their activity
  plus: broadcast on UPDATED only, watchdog/startup gating on the CAS bool,
  the bulk close running with zero event subscribers (the #1714 gate scopes
  the event, never the close), the pull sink on applied-but-not-replayed/
  conflict, and the lease reaper's requeued_execution_ids.

test_1804_terminal_activity_chain.py — the two CAS predicates live in two
different tables; mocks encode what the author believed, so these run the real
statements and assert the tables agree. Includes the late-SUCCESS upgrade end to
end and the cancel-is-never-overwritten mirror image.

test_1804_terminal_activity_parity.py — anchored on terminal WRITES, not on
completion-event emission. The emit set is a strict subset (both shutdown
writers emit nothing), which is exactly how they hid from review. Explicit
allowlist, each entry justified by lifecycle position, plus a self-test that the
guard actually fires and a staleness check on the allowlist.

R4 (terminate byte-identical) lives in test_1332, which the previous commit
updated to the new call surface.

tests/lint_sys_modules.py: clean, no new violations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New canonical block "Terminal-Activity Close Contract (#1804)" — one home for
the feature, pointers from the places that used to imply the old model:

- Fire-and-Forget Dispatch (#1083): the close is a property of winning the CAS,
  not of holding the activity_id local; apply_result is one writer among eight.
- Task Completion Events (#1578): the emit set is NOT the close set — both
  shutdown handlers write a terminal and emit nothing, which is why the parity
  guard is anchored on terminal writes.
- Background Services / Cleanup Service: the recovery closes,
  activities_closed_on_recovery, and the backstop moving last in the cycle.
- services catalog: activity_service owns the closer.

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

activity-stream.md — new "The Close Contract (#1804)" section: the owner
(close_execution_activity + the sync spawn wrapper), the state lattice with the
side-by-side execution/activity diagram and why the lookup must agree with the
CAS, the split by cardinality, the full writer table, and the demotion of the
120-minute backstop.

task-execution-service.md — the _write_terminal_and_gate lost-CAS branch (and
the dropped activity_status param), the backend-shutdown close, and a row in
the Activity Tracking table pointing at the contract.

dashboard-timeline-view.md — why the amber bar used to lie, and that no
frontend change was needed: ReplayTimeline was reading a lying database.

feature-flows.md — Recent Updates row.

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

Two real defects the full-suite run surfaced (both green in isolation, red at
5,100 tests — the pollution class the dossier warned about).

1. ActivityCloseOutcome moves db/activities.py -> models.py. Callers compare it
   by `is`, and db_harness evicts `db.activities` from sys.modules per test. Any
   test that imported services.activity_service BEFORE an eviction held one enum
   class while a later `from db.activities import ...` got a different one, so
   every identity check silently went False. models is the leaf everything
   imports and nothing re-imports, so the object stays the same one — and it now
   sits beside ActivityState/TaskExecutionStatus, where a shared db↔service
   contract type belongs. Re-exported through db.activities.

2. Both _recover_execution closes get their own try/except. The terminal write
   and the capacity release have already succeeded at that point; letting a
   close failure propagate flipped a recovered row into the `errors` bucket and
   would make the watchdog retry a row it had already recovered. Fail-open is
   the rule for everything after a committed terminal — this was the one place I
   left it implicit.

Full unit suite: 5136 passed, 14 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review catch. The callback endpoint IS the late-SUCCESS path — the one the
lattice was widened for. A lease reaper that beat the callback has already
FAILED the row and closed the activity FAILED; the execution CAS deliberately
lets a genuine late SUCCESS correct the row ("a FAILED row falls through so a
late SUCCESS can still overwrite a reaper LEASE_EXPIRED"). With a started-only
lookup the callback passed activity_id=None, apply_result closed nothing, and
the pair settled at execution=success, activity=failed — permanently. That is
#1804 inverted, on the exact path the upgrade exists for.

Harmless for a FAILED callback: the close CAS refuses to overwrite an
already-closed activity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_load_git_service` installs Mocks under the real names `database`,
`services.docker_service` and `services.activity_service`. It deliberately
leaves them in `sys.modules` after the loader returns (the reset path imports
`activity_service` lazily at call time) — but nothing put them back afterwards
either, so the Mocks stayed installed for the rest of the session.

That is a silent, ordering-dependent landmine for any later test that lazily
imports one of those names. #1804 makes `cleanup_service._close_stale_slot_activity`
delegate to `activity_service.close_execution_activity`; with this file running
first, `test_1083_lease_reaper.py::test_swallows_errors` awaited the leaked Mock
and died with `TypeError: object Mock can't be used in 'await' expression` — a
real red under CI's pytest-randomly seeds, in a test unrelated to git reset.

Adds the lint-recognised `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
autouse pair, also restoring `services.git_service*` (the loader force-reimports
it against the Mocks, so leaving that behind poisons later importers too).
`tests/lint_sys_modules.py` drops this file from 9 violations to 0.

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

Three review findings on the terminal-activity close contract.

1. The close lattice inherited the upstream's permissive edge (the bug it
   exists to prevent). `_close_predicate` was specified as an authority
   ORDERING (`started < failed < {completed, cancelled}`) but implemented as an
   EXCLUSION copied literally from the execution CAS: `!= 'cancelled'`. Those
   two disagree exactly on the reflexive case — `!= 'cancelled'` matches
   `'completed'`, so a second COMPLETED close was permitted and re-dated
   `completed_at`/`duration_ms`/`error`. Executed against the real path, a
   15-minute activity (`duration_ms=900000`) re-closed COMPLETED became ~9h:
   the exact fabricated-duration symptom #1804 exists to eliminate, arriving
   through #1804's own fix. It is reachable from `_write_terminal_and_gate`'s
   lost-CAS branch, which passes an explicit `activity_id` — so the narrower
   `started|failed` lookup cannot shield it.

   The literal copy was faithful: `update_execution_status`' SUCCESS predicate
   really is `!= CANCELLED` and really does admit `success -> success`. The
   execution row survives that edge only because an upstream guard (the #1083
   callback's authoritative-terminal replay short-circuit) stops the duplicate
   before it reaches the CAS — a protection the activity's new call sites do
   not have. The predicate now states the ordering literally,
   `IN ('started','failed')`, and the branch's five lattice tests gain the
   `X -> X` case they all skipped because it looks like a no-op.

2. A CAS loss to a *successful* row stamped "superseded by ..." onto a clean
   success, and rendered the enum repr while doing it. `error` is now None on
   an authoritative SUCCESS close (a non-NULL `error` on a `completed` activity
   reads as a problem in every activity-derived view — the rule the shipped
   terminate path already follows), and the label uses `.value`, since
   `f"{TaskExecutionStatus.FAILED}"` renders as `TaskExecutionStatus.FAILED` on
   3.11+ — the `str, Enum` footgun #1578 already paid for.

3. `_recover_execution` returning the terminal CAS bool (it must, to gate the
   close) changed what a False MEANS to `recover_orphaned_executions`, which
   counted every False as `errors`. A lost CAS is RELIABILITY-005's guarded
   writer working as designed — a real completion landed during restart — so
   folding it into `errors` makes a healthy restart race read as a failing one,
   and would have made this fix look like it introduced failures. False is now
   partitioned: genuine exceptions self-count into `stats["errors"]`, the
   remainder is reported as `cas_lost`.

Docs updated in lockstep (architecture.md, requirements/scheduling.md,
feature-flows/activity-stream.md) to state the COMPLETED arm as deliberately
tighter than the execution CAS rather than a literal mirror, plus a
learnings.md entry on inheriting a permissive edge when mirroring a predicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review July 28, 2026 23:30
@AndriiPasternak31
AndriiPasternak31 requested review from dolho and vybe July 28, 2026 23:30
@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.

@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.

Validated via /validate-pr. Fixes #1804 with a closing keyword. The close-lattice CAS (db/activities.py::_close_predicate) mirrors the execution-row CAS with a correctly-argued tightening (no COMPLETED->COMPLETED re-date), close_execution_activity is the single owner with tri-state outcome semantics (ALREADY_CLOSED as designed no-op), and include_failed=True is applied exactly on the late-SUCCESS callback path. Named #1804 regression suites (R1-R5, chain, parity) present; docs complete; full pytest matrix + pg-migrations green; security scan clean. Will resolve the expected docs/routers conflicts against dev after #1867 lands, then merge.

… 07-28 entries)

Both docs-only both-sides-added conflicts vs the just-merged #1816 (#1867):
keep the #1804 and #1816 changelog rows, and all four 2026-07-28 learnings
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vybe
vybe merged commit a8418a2 into dev Jul 29, 2026
24 checks passed
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