Repository navigation
fix(scheduler): reconcile stuck once tasks from committed run outcome - #5035
WillemJiang merged 18 commits into
Conversation
Huixin615
left a comment
There was a problem hiding this comment.
Blocking: reconcile the actual latest run, including skipped.
TERMINAL_RUN_STATUSES already includes skipped, but neither recovery path handles it correctly:
-
cancel_stuck_once_tasks reads the latest row but lets skipped fall through to generic cancellation.
-
reconcile_stuck_once_tasks filters to success/failed/interrupted before ordering, so it retrieves the latest matching terminal row, not necessarily the task’s latest run. With retained history, it can ignore a newer skipped or active row and apply an older success, incorrectly marking the parent completed.
Please fetch the latest scheduled_task_runs row without a status filter in both paths, then branch on that exact row’s status. skipped should have an explicit parent-state mapping rather than being treated as “no terminal run.” Please also add regression cases with multiple historical runs, including an older success followed by a newer skipped/active row, for both reconciliation paths.
A few test concerns:
-
TestHandleRunCompletionHappyPath never calls ScheduledTaskService.handle_run_completion; it manually performs the two expected repository writes. It would still pass if the real completion hook regressed. Please invoke the production method or remove the claim that this test covers it.
-
The DST tests are unrelated to this fix and call croniter directly rather than DeerFlow’s next_run_at. The spring-forward test also only checks tzinfo is not None twice, which does not prove that a nonexistent local time was normalized to a valid instant. I suggest removing these from this PR or moving them to a focused follow-up with concrete production-level assertions.
-
Please update the relevant scheduler/recovery documentation in backend/AGENTS.md.
@Totoro-qaq Thanks again for your PR, please look at the suggestions.
ba2d87f to
a1de88a
Compare
|
Thanks for the thorough review, @Huixin615. All 4 points addressed: 1. 2. Fetch latest row without status filter — 3. Happy-path test — Rewritten to construct a real 4. AGENTS.md — Added one-line note documenting the outcome-aware reconciliation. Additionally:
Local CI gate results (identical commands to CI): |
willem-bd
left a comment
There was a problem hiding this comment.
Thanks — this head cleanly resolves the earlier round: both recovery paths now fetch the latest scheduled_task_runs row without a status filter, skipped gets an explicit parent mapping, multi-history regressions cover older-success/newer-skipped/active for both paths, the happy-path test drives the real handle_run_completion, and backend/AGENTS.md documents the new behaviour. The remaining findings below are minor maintainability items. One doc note (no code change): the PR description still advertises the four DST/croniter regression tests that were removed from this revision — worth trimming that paragraph so the description matches the 17 tests actually shipped.
a1de88a to
087aa32
Compare
willem-bd
left a comment
There was a problem hiding this comment.
[P1] Refresh the latest run after acquiring the task lock
reconcile_stuck_once_tasks() batch-loads ORM run rows before locking each task. A completion can commit scheduled_task_runs.status = success after _fetch_latest_runs() returns but before that task is finalized. The cached ORM row still says running, so _finalise_once_task_from_run() applies the generic cancellation and recreates the inconsistency this PR is intended to fix.
I reproduced this deterministically by changing the run from running to success immediately after _fetch_latest_runs() returned: reconciliation marked the parent cancelled instead of completed.
Please lock and refresh the latest ScheduledTaskRunRow after acquiring the task lock, likely with SELECT ... FOR UPDATE plus populate_existing, or make the run and parent completion updates atomic. The outcome used for finalization must not come from the pre-lock batch snapshot.
087aa32 to
623505f
Compare
|
Thanks for the sharp P1, @willem-bd — you're right, the pre-lock batch snapshot reintroduced exactly the bug this PR fixes. Fixed. Root cause of the race Fix
Tests Local CI gate results (same commands as CI): One outstanding doc note from your earlier round: the PR description still mentions the DST/croniter tests that were removed. Want me to trim that paragraph, or leave the description as-is? |
Huixin615
left a comment
There was a problem hiding this comment.
@Totoro-qaq Thanks for the follow-up. The production implementation at the latest head now addresses the previous findings: both paths use the actual latest run, skipped is explicit, the mapping is shared, the real completion hook is exercised, and the pre-lock batch snapshot has been removed.
One remaining test issue:
[P2] The new race tests do not enter the race window
Both test_cancel_sees_run_committed_before_finalize and test_reconcile_sees_run_committed_before_finalize commit status="success" before invoking the recovery method. The previous buggy batch-snapshot implementation would also pass these tests, because its initial batch query would run after that commit and already observe success.
Please make the status transition occur after recovery has observed the stale running state but before parent finalization—for example, by using an event/barrier or wrapping/monkeypatching the per-task fetch so a separate transaction commits success at that exact point. The test should fail against the reverted pre-lock batch implementation and pass against the current implementation.
|
@Totoro-qaq please take a look at the review comments. |
623505f to
616b6f2
Compare
|
Thanks for the precise P2, @Huixin615 — you're right, the previous tests committed P2 fix Both race tests now use monkeypatch to intercept
A reverted pre-lock batch implementation never calls Also fixed a Verification: lint clean, 19/19 test file passed, full suite 12391 passed / 78 skipped. |
willem-bd
left a comment
There was a problem hiding this comment.
Requesting changes for the unresolved P1 in the existing inline discussion: recovery must not terminalize a once-task parent while its latest scheduled occurrence is still queued, launching, or running.
The preceding scheduled-run sweep and parent reconciliation are exception-isolated and not atomic. I also reproduced a second interleaving at this head: _fetch_latest_run() returned the latest occurrence as running, a separate completion transaction then committed that row as success, and recovery still committed the parent as cancelled from the stale ORM object. The focused suite does not cover this window because its race transition happens before the production fetch.
Please leave active occurrences unchanged for a later recovery pass, or reconcile/lock the scheduled occurrence, linked durable run, and parent atomically before finalization. Existing inline discussion: #5035 (comment)
Validation: the existing focused suite passes (19 tests), Ruff and formatting pass, and the added post-fetch race reproduction fails with cancelled != completed.
616b6f2 to
8757798
Compare
|
Thanks for the deep P1, @willem-bd — you're right, I was still finalizing parents for active occurrences. Fixed. Root cause
Fix
Verification: lint clean, 19/19 test file passed, full suite 12391 passed / 78 skipped. Pushed as |
willem-bd
left a comment
There was a problem hiding this comment.
This head resolves both prior P1s cleanly: the latest run is now read per task after the parent lock with populate_existing (_fetch_latest_run), and active occurrences (queued/launching/running) are left untouched instead of being generic-cancelled, with the multi-history and monkeypatch race regressions covering both paths. The four items below are follow-ups on the new early-return: the main one is that its "a later recovery pass will finalize it" justification only holds for the multi-instance caller — the single-instance path has no such pass, so a task that hits the branch there stays running until the next restart. Doc note carried over from the earlier round (no code change): the PR description still advertises the removed DST/croniter test section.
8757798 to
23dc63d
Compare
|
Thanks for the thorough follow-up, @willem-bd. All four items addressed: [P2] Single-instance path leaves task permanently "running" [Suggestion] Use TERMINAL_RUN_STATUSES / ACTIVE_RUN_STATUSES [Nit] Stale docstring in [Nit] Bonus: Verification: lint clean, 19/19 test file passed, full suite 12391 passed / 78 skipped. Pushed as |
willem-bd
left a comment
There was a problem hiding this comment.
Thanks — this head resolves both prior P1s: the latest run is now re-read per task after the parent lock with populate_existing (_fetch_latest_run), active occurrences (queued/launching/running) are left untouched instead of generic-cancelled, and the single-instance poll loop now re-runs cancel_stuck_once_tasks so the "a later recovery pass will finalize it" justification actually holds there. The shared _finalise_once_task_from_run helper and its bool return also close out the earlier duplication/no-op-argument nits, and the new NOTE at scheduled_tasks/sql.py:16 is correct — I confirmed the cycle is real (scheduled_task_runs/sql.py imports deerflow.persistence.scheduled_tasks.model, whose package __init__ re-imports scheduled_tasks.sql, the module mid-import).
The items below are follow-ups, none blocking: the multi-history regressions don't actually control the column that decides "latest", the id DESC tie-break is not a recency key, the helper's return annotation contradicts its contract, and several new test docstrings still describe the leave-untouched branch as a generic cancel.
(No code change, carried over: the PR description still advertises the removed DST/croniter test section.)
|
Addressed the follow-up items from the review (head updated to
Also trimmed the PR description to drop the removed DST/croniter test section, matching the 19 tests actually shipped. Verification: Note: CI on this new head is gated by the first-time-contributor "Approve and run workflows" action — could you approve the new check runs so we can confirm |
|
One final cleanup push: Verification on this exact change:
One remaining CI/documentation question: the requested one-line |
willem-bd
left a comment
There was a problem hiding this comment.
This head cleanly resolves both prior P1s: the latest run is re-read per task after the parent lock (_fetch_latest_run with populate_existing), active occurrences are left untouched instead of generic-cancelled, and the single-instance poll loop now re-runs cancel_stuck_once_tasks so the "a later recovery pass will finalize it" justification holds there. The canonical status constants in scheduled_tasks/model.py also close out the duplication/NOTE round, and the test helper's created_at write-back makes the multi-history ordering real. Three follow-ups below — the first is the substantive one: the ordering key behind latest run changed this revision from created_at DESC, id DESC to scheduled_for DESC with no tie-break, and scheduled_for is not a recency key.
d367e2c to
cd75cbb
Compare
|
Rebased onto current
Validation at |
Huixin615
left a comment
There was a problem hiding this comment.
One non-blocking documentation nit: persistence/migrations/AGENTS.md still describes the local chain as ending at 0019_thread_incarnations, while the current head is now 0022_scheduled_occurrence_seq
|
@Huixin615 Thanks, fixed in |
Restart recovery (cancel_stuck_once_tasks and the multi-instance reconcile_stuck_once_tasks) blindly flipped every stuck once-task to 'cancelled'. When handle_run_completion crashed between its two transactions, a once-task whose run had already committed 'success' was permanently reported as cancelled. Both reconciliation paths now read the latest scheduled_task_runs row without a status filter and finalize the parent to match: success -> completed (last_error cleared), failed -> failed with the run's error, interrupted -> cancelled with the run's error when present, skipped -> cancelled (no work performed). Active occurrences (queued/launching/running) are left untouched — a concurrent completion or a later recovery pass will finalize them once the run reaches a terminal state. Tasks without a terminal run row keep the previous generic cancellation. Review follow-ups (willem-bd / Huixin615): - Extract _finalise_once_task_from_run() so both recovery paths share one outcome mapping (no more drift between single- and multi-instance paths). Returns bool (True = finalised, False = active/no-op) for explicit counter management at call sites. - Fix a no-op (`run_row.error or None` -> `run_row.error`) in the skipped branch. - Drop the unused `status` parameter from the test task helpers. - Use TERMINAL_RUN_STATUSES / ACTIVE_RUN_STATUSES constants (local copies to avoid circular import; kept in sync with scheduled_task_runs.sql). - [P1] Read the latest run AFTER acquiring the parent task row lock, not from a pre-lock batch snapshot. The latest-run lookup now runs per task under the lock with populate_existing so a concurrently committed status is read back fresh. - [P2] Race tests now use monkeypatch to actually enter the race window: _intercepted_fetch commits success in a separate session at the moment the per-task fetch fires, so a reverted pre-lock batch implementation fails the test, while the current post-lock implementation passes. - [P1] Do not finalize parent for active occurrences. A non-terminal scheduled occurrence means the run is still in progress — the parent must be left untouched until the completion path or a later recovery pass establishes a terminal outcome. - [P2] Add cancel_stuck_once_tasks to the single-instance poll loop so stuck once-tasks are not left permanently "running" when the startup sweep fails (mirrors multi-instance _reconcile_active_state behavior). - Fix stale docstrings in cancel_stuck_once_tasks and _fetch_latest_run. Adds regression tests for multiple historical runs (older success + newer skipped/active) on both paths, monkeypatch-based race tests that prove a concurrent completion committing success is reflected as completed, and active-run tests that verify the parent is left unchanged. Documents the behavior in AGENTS.md. Fixes bytedance#5034
- _fetch_latest_run: drop arbitrary id DESC tie-break; order by scheduled_for DESC (deterministic recency on schedule position) - _finalise_once_task_from_run: annotate bool return type - Centralize TERMINAL/ACTIVE_RUN_STATUSES in scheduled_tasks/model.py; stop duplicating them in scheduled_tasks/sql.py and scheduled_task_runs/sql.py (removes stale circular-import workaround) - cancel_stuck_once_tasks: run unconditionally in single-instance poll loop (remove try/except swallow) - tests: pin created_at/scheduled_for in _create_run so recency ordering is actually exercised; correct docstrings that described the active-occurrence branch as 'generic cancel' instead of 'left unchanged'
Signed-off-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Keep single-instance parent reconciliation at startup so it cannot race manual admission. Propagate recovery failures through the Gateway lifespan before channel startup, preventing a half-started scheduler. Tests cover both recovery failure stages and a queued occurrence that survives startup before the ordinary poll drain launches it. Signed-off-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Allocate per-task occurrence sequences under the parent lock and guard parent projection across launch, recovery, completion, and queue failure paths. Track launch accounting separately so stale occurrences are counted once without replacing newer results. Commit completion and accounting atomically, preserve legacy history, and cover migrations and reordered execution on SQLite and PostgreSQL.
…nostics Share the once-task outcome mapping between completion and both recovery paths, validate the terminal status before opening the completion transaction, leave cron parent status untouched on completion, log the fenced launch update when an occurrence does not belong to the launched run, and drop the README capability line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Among sequenced rows the parent-locked occurrence_seq is the only recency key. An unsequenced row can only be legacy history or an admission by a pre-upgrade Gateway writer, so recovery prefers it over the sequence winner only when its caller timestamp is later, which is the previous ordering for that pair. A rolling upgrade therefore degrades to the pre-sequence behaviour instead of ranking every pre-upgrade admission below every sequenced one. Document that boundary instead of requiring every Gateway writer to stop before the upgrade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Recovery now finalises a once-task parent only from the occurrence that can_project() accepts: the highest sequenced occurrence whenever one exists, or the timestamp-latest row for a task whose history is entirely unsequenced. An unsequenced row admitted by a pre-upgrade writer can no longer cancel a parent whose sequenced occurrence is still live, nor stall finalisation of a parent whose sequenced occurrence already completed. Document that pre-upgrade instances project their own admissions during a rolling upgrade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
uq_scheduled_task_run_active allows one non-terminal occurrence per task, so a live row is the task's newest admission whatever its caller clock and whether it carries a sequence. Both once-task recovery paths now probe for any active occurrence after the fresh latest-run read and leave the parent untouched while one exists; cancel_stuck_once_tasks also locks the parent row so admission cannot insert a queued occurrence between that probe and the commit. Once no occurrence is live, the sequence winner decides and a terminalised unsequenced row never overrides it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Main's forward-revision tests assumed 0019_thread_incarnations was the local chain head. With 0022_scheduled_occurrence_seq chained after it, seed the canonical-0019 shape explicitly, assert the real head where a database is upgraded, derive the 0020 rollback binary's revision set from the ancestors of its head, and step the PostgreSQL restart scenario back to canonical 0019 before the rollback binary restarts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e_seq The rolling-forward section still ended the local chain at canonical 0019; it now names 0022_scheduled_occurrence_seq as the head and lists it among the revisions the 0020 rollback-floor binary does not know. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cbf46fc to
7f7ccc1
Compare
CI hands over TEST_POSTGRES_URI as postgresql://...?sslmode=disable. The occurrence, ordering and 0022 migration fixtures built async engines from it directly, so SQLAlchemy chose psycopg2, which is not installed. Normalize the scheme to postgresql+asyncpg and drop libpq-only query keys, matching the existing 0019 migration tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The middlewares guidance chain was already above the hard limit on main, so any added byte in backend/AGENTS.md fails the agent guidance check. Leave backend/AGENTS.md identical to main and record the recovery projection rule in the 0022 migration entry, which already describes the occurrence fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both CI failures on 7f7ccc1 came from this PR's own test setup and docs, not scheduler logic, and are fixed in aa3fe16: the new Postgres fixtures now accept CI's |
…ification outbox (#6135) * feat(persistence): add notification delivery outbox for scheduled task IM push New notification_deliveries table and repository (migration 0012) backing the outbox pattern for issue #4254: idempotent enqueue keyed on task_run_id plus event plus provider plus target, atomic claim of due rows, and exponential-backoff retry state moving pending to sending to sent or failed. Execution status stays in scheduled_task_runs; delivery status lives here. * feat(scheduler): enqueue and deliver scheduled-task notifications over IM channels Closes the loop for issue #4254. On run completion the scheduler writes outbox rows for the task owner's connected channel identities (success and failure events; interrupts stay silent), and a new NotificationDeliveryWorker polls due rows and pushes them through the channel's proactive send path. Channel gains send_notification; WeCom implements it via the WS client's send_message with errcode checking, the base class raises for providers without proactive push. Delivery is best-effort and never shadows execution status: enqueue and send failures are logged and recorded in the outbox, which retries with backoff. Channel liveness is re-checked at delivery time, and the worker stops before the channel service on shutdown. Feature activates only when channel connections are enabled; no new dependencies. Verified end to end against a live WeCom bot: scheduled run completion produced an outbox row that was delivered in under one second. * feat(scheduler): include scheduled run result summary in IM notifications The v1 notification only carried the run outcome skeleton (status, task id, run id). Completed runs now include the agent's final answer so the IM push is actually useful headlessly. - The delivery worker accepts an optional resolve_run_summary(run_id, user_id) resolver; for run_completed events it enriches the rendered text with a "Result:" line (bounded to 1000 chars). The lookup is best-effort: resolver failures or missing summaries degrade to the skeleton text and never block or fail the delivery. - The summary is read at delivery time from runs.last_ai_message (the convenience column written on run completion), not snapshotted into the outbox payload; retried deliveries re-read the freshest value and the outbox row payload stays skeleton-only. The claimed outbox row is never mutated in place. - Gateway wiring scopes the read to the outbox row's owner_user_id so a stale row cannot pull another user's run content. - Failed runs keep the error-only skeleton: a partial answer on a failed run would be misleading. Tests: 9 new worker cases (rendering incl. truncation, sync/async resolvers, failure/missing fallbacks, no resolver call for failed runs). Full scheduled + outbox + channels regression green (441 passed), ruff check + format clean. * fix(scheduler): address review feedback on notification delivery reliability Review follow-ups for the scheduled-task IM notification outbox: 1. Crash recovery for "sending" rows: claim now stamps updated_at and the worker reconciles rows stuck in "sending" past a 10-minute lease back to "pending" before every claim. Per-row try/except isolates a crashing delivery from the rest of the claimed batch. 2. Double-send race under multiple workers: claim returns exactly the rows its own UPDATE...RETURNING flipped (guarded by status='pending'), so a rival worker's concurrent claim can no longer be handed out twice. 3. Restore non-ASCII comments and drop the BOM accidentally introduced in test_scheduled_task_service.py. 4. Manual "run now" completions no longer enqueue IM notifications; the user is already watching the UI. Missing trigger metadata fails safe towards notifying. 5. Channel-outage failures no longer consume the retry budget: they are parked with count_attempt=False on a flat 15-minute backoff so an hours-long outage cannot exhaust retries and drop the notification. Tests: repository 16, worker 21, scheduled-task service 31; targeted regression across notification/scheduler/channel suites: 511 passed. * fix(scheduler): address round-2 review on IM notification delivery Park WeCom transport/outage failures without burning retries, bound worker stop, include task titles, align enqueue with delivery, and document the IM push behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(scheduler): complete IM notification docs and drop unused list API Add backend/channels/migrations AGENTS notes, CHANGELOG and config.example hints for the outbox push path, and remove the unused list_by_task_run repository method flagged in review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scheduler): address round-3 review on IM notification delivery Cap channel-down parking by row age and parked attempts, redact IM egress content, narrow WeCom transport exceptions, add lifespan wiring tests, and run ruff format so lint-backend passes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scheduler): redact entire PEM blocks in IM egress text The previous BEGIN-only pattern left key material and the END marker in notifications. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(persistence): align 0014 parked_attempts with bootstrap schema tests Backfill existing rows with a temporary server default, then drop it so create_all matches Alembic, and move HEAD anchors to 0014. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(persistence): format notification tests and shorten alembic revision id ruff format --check failed on test_notification_delivery_worker.py. 0015_notification_delivery_parked_attempts exceeds alembic_version.version_num (VARCHAR 32) and truncates on Postgres CI; use 0015_parked_attempts instead. * test: fix post-merge CI failures after migration renumber Update migration 0015 test HEAD expectation to 0017_parked_attempts and add queue_timeout_seconds to the lifespan notification worker mock scheduler config. * fix(persistence): chain the notification outbox migrations after 0026 Main's head moved from 0016 to 0026_mcp_task_lease_tokens, so the two outbox revisions are renumbered and re-parented: - 0017_notification_deliveries -> 0027_notification_deliveries - 0018_parked_attempts -> 0028_parked_attempts Only the revision ids, parents and the migration guide change. * fix(scheduler): adapt the notification hook and its docs to main - tests: the completion tests assert on the atomic complete_run record; new cases cover a completion that was not recorded, a task deleted meanwhile, a failed task read, and the outbox being off. - AGENTS.md: drop the root note and shorten the backend note to a pointer; the two notes pushed two effective guidance chains past the 96 KiB limit. - CHANGELOG: move the entry from the released 2.1.0 section to Unreleased and add its link references. * test(persistence): move the chain-head pin to the outbox migrations test_migration_0026 pinned 0026 as the head, so it failed once the outbox revisions chained after it. It now checks chain membership like the older migration tests, and a new test file owns the head pin, checks the parents and the 32-character revision id limit, and runs the upgrade and downgrade. * fix(gateway): wire scheduled-task notifications after the channel service starts The outbox wiring sat in the scheduler block and asked for the running channel service there. #5035 moved the channel service start to after the scheduler, and the two changes merged without a conflict, so on every startup the lookup returned None and the feature disabled itself with the "write-only outbox" warning. The lifespan tests patched the lookup to a constant and kept passing. The wiring now runs in its own step after the channel service start. The scheduler is built as on main, and ScheduledTaskService gains attach_notification_outbox() so the enqueue side is enabled once the delivery worker is running. The guard, the worker and the shutdown order are unchanged. Tests: the lifespan helper can model the real accessor (None until the channel service has started); a new case fails without this fix. * fix(scheduler): enqueue from scheduler start and keep the push when the title read fails Follow-up to the startup-order fix, from review. - The enqueue side is wired with the scheduler again (it only needs the durable table), and only the delivery worker waits for the channel service. Attaching it later left a window after the scheduler started in which a finished run wrote no outbox row. If the channel service is missing or the worker cannot start, the enqueue side is detached, so the outbox still never becomes write-only. - A failed task read after complete_run no longer drops the notification: the title is optional and the message falls back to the task id. A task deleted meanwhile still stays silent. * fix(channels): park WeCom notifications while the SDK socket is closed The WeCom SDK raises a plain RuntimeError when its socket is not open, and send_notification only mapped ConnectionError, TimeoutError and OSError to ChannelUnavailable. A disconnect therefore spent the counted retry budget and settled the row as failed after about 15 minutes, although the docs say transport outages park without consuming retries. send_notification now asks the client's is_connected first. The new test uses a real, never-connected SDK client: it raised RuntimeError after three retries before, and parks now. * docs(scheduler): describe which outcomes notify; mirror the changelog entry - README (en/zh) and CONFIGURATION.md: only runs that finish as success or failed notify; occurrences that end without a finished run (launch error, queue timeout, restart recovery) do not. Parking lasts up to about a day. - CHANGELOG: cite the issue as text like the rest of the file, and add the entry and link reference to CHANGELOG_zh.md. - AGENTS.md: drop the backend pointer so this branch adds nothing to the guidance chains closest to their size limit; the scheduler-side rules and the lifespan order now live in app/channels/AGENTS.md. * docs(changelog): cite #6135 next to #4843 for the notification outbox entry * fix(scheduler,channels): re-check the IM binding at delivery time; park WeCom transport errors, count its rejections Review round 1 on #6135. The delivery worker now takes resolve_connections (the Gateway wires ChannelConnectionRepository.list_connections) and, before the channel liveness check and the summary read, requires the row's owner to still hold a connected (provider, external_account_id). A disconnected target is finalized with mark_failed(terminal=True); a lookup failure or an unexpected result shape parks the row. Terminal outbox rows ignore later status writes. WeCom: the SDK raises RuntimeError from send_message for a socket that is not open and for a dropped connection, but also for a platform rejection ("Reply ack error: errcode=..."). Only that prefix keeps counting; other RuntimeErrors, ConnectionError/TimeoutError/OSError and websockets' ConnectionClosed park the row as ChannelUnavailable. * fix(channels): treat a WeCom ACK rejection as proof the transport is up; pin the rejection prefix through the retry branch and the real connection-row shape --------- Co-authored-by: GaoTianyuanhr <75788039+GaoTianyuanhr@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: GaoTianyuanhr <GaoTianyuanhr@users.noreply.github.com> Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Fixes #5034
Why
ScheduledTaskService.handle_run_completionfinalized a run in two transactions: first thescheduled_task_runsrow, then the parent task. A crash between them left aoncetask atrunningeven though its run outcome was already durable, and recovery then cancelled it without consulting the committed outcome, so a successful run could be reported as cancelled.Review found two further gaps on the same path: recovery ordered occurrences by caller timestamps, which are not a cross-instance recency key, and a late older occurrence (launch lease expired, a newer occurrence admitted and failed, then the older run returned) could overwrite the newer parent result through both recovery and the normal completion callback.
What changed
success→completed,failed→failed,interrupted/skipped→cancelled); active occurrences are left to the queue owner. Single-instance startup recovery runs occurrence → parent in order and fails closed before request admission.occurrence_sequnder the existing parent lock. Launch, recovery, completion and queue-failure paths project an occurrence onto the parent only when it is the task's newest sequenced occurrence, so a late older run can no longer replace a newer result.launch_accountedmarker counts each proven launch exactly once even when a late older launch is fenced, and completion commits the occurrence, its accounting and the eligible parent outcome in one parent-first transaction.0022_scheduled_occurrence_seq(single head, chained after canonical0019_thread_incarnations). Existing rows are not backfilled. The new fields are internal and omitted from repository/API responses; history display and cross-task FIFO are unchanged.Surface area
frontend/backend/app(scheduler recovery and completion; Gateway startup fails closed on single-instance recovery failure; migration0021)langgraph.json, or prompt changedocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.json(say what it buys us)Bug fix verification
backend/tests/test_scheduler_completion_consistency.py(stuck-parent, skipped, active and race cases). Red before the fix, green here.test_once_recovery_uses_occurrence_order_despite_clock_skew(SQLite) andtest_postgres_once_recovery_uses_occurrence_order_despite_clock_skew(PostgreSQL); 16 ids red on timestamp ordering, green with sequence ordering.backend/tests/test_scheduler_occurrence_ordering.py(red on the sequence-only prototype) andbackend/tests/test_scheduler_completion_atomicity.py.test_recovery_lookup_prefers_the_highest_sequence_whenever_one_existsandtest_once_recovery_defers_while_any_occurrence_is_live_then_projects_the_sequence_winner(both recovery paths, SQLite and PostgreSQL); the active-row ids were red before the deferral rule, green here.Validation
Head
4cec5f0e:make test-blocking-io: 116 passed.make lintandruff format --check: pass (1373 files).4cec5f0e(14,433 in theuv run pytestfull run,followup-full-offline-tests-uvrun.log; the 15 browser/fetch cases that need public DNS were rerun in a terminal with normal networking, 15 passed,user-terminal-baseline15.log), 165 skipped, 3 deselected.4cec5f0ewith a local replay model endpoint and no external model calls. Not exercised: OS process kill, multiple OS processes/Kubernetes.AI assistance
Tool(s) used: Codex, Claude Code.
How you used it: Source investigation, implementation and regression tests, independent code review of the diff, and local SQLite/PostgreSQL validation with controlled runtime interleavings. The design choices (sequence allocated under the parent lock, parent-write fencing, the launch-accounting marker, the rolling-upgrade fallback) were decided and reviewed by the author.
🤖 Generated with Claude Code