fix(execution): the CAS-won terminal owns the paired activity close (#1804) - #1863
Conversation
…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>
|
Resolve by running |
vybe
left a comment
There was a problem hiding this comment.
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.
Fixes #1804
Problem
Every terminal writer outside the dispatching coroutine wrote the terminal to
schedule_executionsand walked away. The pairedagent_activitiesrow stayedactivity_state='started'— the Dashboard Timeline rendered the agent as still working (ReplayTimeline.vue) until the generic 120-minute sweep closed it with a fabricatedduration_ms = now − started_atthat nothing ever recomputes. A 15-minute run was permanently recorded as a ~120-minute failure.The close was gated on holding the
activity_idlocal and winning the CAS in the same coroutine, so it failed two independent ways: process death (nofinally, the id dies with the coroutine) and CAS loss (_write_terminal_and_gatehad 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 syncspawn_close_execution_activitywrapper for the pull sink), structurally the twin ofevent_dispatch_service.spawn_task_terminal_event. It maps the terminal via the sharedmodels.activity_state_for_terminal(#1332 — never a second mapping), delegates tocomplete_activityso theagent_activityWS 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_activitynow returnsActivityCloseOutcome(UPDATED/ALREADY_CLOSED/NOT_FOUND) — one bool cannot answer both "did this row exist" (routers/internal.py404s 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 acceptsstarted|failed(so the #1083 late-SUCCESS-after-lease-expiry path may upgrade a provisional FAILED); CANCELLED/FAILED acceptstartedonly. 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=Truefor 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_activitiesis a sibling of_emit_bulk_terminal_events, never folded into it — that method short-circuits onhas_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-shutdownCancelledErrorhandlers, 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, andterminate_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
!= 'cancelled'), and those disagree exactly on the reflexive case:!= 'cancelled'matches'completed'. A second COMPLETED close re-datedcompleted_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 explicitactivity_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."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). Nowerror=Noneon an authoritative SUCCESS close,.valuefor the label._recover_executionreturning the CAS bool changed what aFalsemeans torecover_orphaned_executions, which counted everyFalseaserrors. A lost CAS is RELIABILITY-005 working as designed, so it's now partitioned intocas_lost; genuine exceptions self-count.Observability
CleanupReport.activities_closed_on_recovery.stale_activitiesshould trend to ~0 while this picks up the volume — a non-zerostale_activitiesnow 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_failednow returns the CAS-won count rather than the candidate count (it previously over-reportedreport.no_session_executionsby counting rows whose guarded UPDATE lost). Its siblingmark_stale_executions_failedalready 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.py9 → 0).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 withassert 5 == 2— itsset_ceilingmonkeypatch doesn't reachclamp_to_ceiling. Attribution was done by replaying the literal failing order on pristineorigin/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