Skip to content

fix(scheduler): reconcile stuck once tasks from committed run outcome - #5035

Merged
WillemJiang merged 18 commits into
bytedance:mainfrom
Totoro-qaq:fix/scheduler-completion-consistency
Sep 11, 2026
Merged

WillemJiang merged 18 commits into
bytedance:mainfrom
Totoro-qaq:fix/scheduler-completion-consistency

Conversation

@Totoro-qaq

@Totoro-qaq Totoro-qaq commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5034

Why

ScheduledTaskService.handle_run_completion finalized a run in two transactions: first the scheduled_task_runs row, then the parent task. A crash between them left a once task at running even 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

  • Once-task recovery reads the actual latest occurrence and finalizes the parent from its committed outcome (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.
  • Each new occurrence receives a database-backed per-task occurrence_seq under 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.
  • An internal launch_accounted marker 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.
  • Migration 0022_scheduled_occurrence_seq (single head, chained after canonical 0019_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.
  • Rolling upgrades: upgraded instances project only sequenced occurrences and defer recovery while any occurrence is still live, whichever instance admitted it; rows admitted by pre-upgrade instances are projected by those instances themselves, and the ordering guarantees hold once every Gateway writer runs the upgraded version (README Upgrade Notes).

Surface area

  • Frontend UI — page / component / setting / interaction under frontend/
  • Backend API — endpoint / SSE event / request-response shape under backend/app (scheduler recovery and completion; Gateway startup fails closed on single-instance recovery failure; migration 0021)
  • Agents / LangGraph — agent node, graph wiring, langgraph.json, or prompt change
  • Sandbox — docker/ or sandboxed execution
  • Skills — change under skills/
  • Dependencies — new/upgraded entry in backend/pyproject.toml or frontend/package.json (say what it buys us)
  • Default behavior change — once-task recovery finalizes from the committed outcome instead of cancelling; a stale occurrence no longer overwrites a newer parent result
  • Docs / tests / CI only — no runtime behavior change

Bug fix verification

  • Recovery misreporting a successful run as cancelled: backend/tests/test_scheduler_completion_consistency.py (stuck-parent, skipped, active and race cases). Red before the fix, green here.
  • Reversed/equal caller timestamps between occurrences: test_once_recovery_uses_occurrence_order_despite_clock_skew (SQLite) and test_postgres_once_recovery_uses_occurrence_order_despite_clock_skew (PostgreSQL); 16 ids red on timestamp ordering, green with sequence ordering.
  • Late older launch overwriting a newer result, and launch counting: backend/tests/test_scheduler_occurrence_ordering.py (red on the sequence-only prototype) and backend/tests/test_scheduler_completion_atomicity.py.
  • Pre-upgrade writer rows during a rolling upgrade: test_recovery_lookup_prefers_the_highest_sequence_whenever_one_exists and test_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:

  • Focused SQLite/PostgreSQL scheduler, ordering, completion, migration, bootstrap and guidance tests: 175 passed (PostgreSQL 17 in a disposable container).
  • make test-blocking-io: 116 passed. make lint and ruff format --check: pass (1373 files).
  • Full offline backend run: 14,448 executed cases pass at 4cec5f0e (14,433 in the uv run pytest full 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.
  • Twelve controlled real-runtime scenarios (Gateway lifespan, RunManager, workers, LangGraph, checkpoints, SQL repositories; SQLite and PostgreSQL single-/multi-instance; interrupted recovery and late normal completion) pass at 4cec5f0e with 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.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

🤖 Generated with Claude Code

@CLAassistant

CLAassistant commented Aug 26, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/L PR changes 300-700 lines labels Aug 26, 2026

@Huixin615 Huixin615 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

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

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

  3. Please update the relevant scheduler/recovery documentation in backend/AGENTS.md.

@Totoro-qaq Thanks again for your PR, please look at the suggestions.

@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from ba2d87f to a1de88a Compare August 26, 2026 16:05
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @Huixin615. All 4 points addressed:

1. skipped handling — Both recovery paths now explicitly branch on skipped → parent finalised as cancelled (no work performed). Docstring updated.

2. Fetch latest row without status filter — reconcile_stuck_once_tasks query changed from status.in_(("success", "failed", "interrupted")) to unconditional order_by(created_at.desc(), id.desc()).limit(1). Ensures a newer skipped/active row is never skipped over in favour of an older terminal run.

3. Happy-path test — Rewritten to construct a real ScheduledTaskService and call service.handle_run_completion(record) instead of manually performing two repo writes.

4. AGENTS.md — Added one-line note documenting the outcome-aware reconciliation.

Additionally:

  • Removed the DST tests from this PR (as suggested) — they call croniter directly rather than DeerFlow's next_run_at.
  • Added 4 regression tests for multiple historical runs: older success + newer skipped → cancelled, older success + newer running → generic cancel, for both cancel_stuck_once_tasks and reconcile_stuck_once_tasks.
  • Cleaned up duplicate inline imports to top-level.

Local CI gate results (identical commands to CI): make lint clean, make test 12389 passed, PG tests 20 passed, new test file 17 passed.

@github-actions github-actions Bot added area:docs Documentation and Markdown only size/XL PR changes 700+ lines and removed size/L PR changes 300-700 lines labels Aug 26, 2026

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

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.

Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/tests/test_scheduler_completion_consistency.py Outdated
@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from a1de88a to 087aa32 Compare August 27, 2026 07:43

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

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

@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from 087aa32 to 623505f Compare August 28, 2026 02:48
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

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
reconcile_stuck_once_tasks() loaded all latest run rows up front (before the loop), then locked each task row and finalized from the cached snapshot. A completion committing scheduled_task_runs.status = success between the batch load and finalization left a stale running row in the session, so _finalise_once_task_from_run() applied the generic cancellation — marking a successful task cancelled again.

Fix

  • Reverted the N+1→batch idea on the locked path (correctness beats the micro-opt here). The latest run is now read per task, after session.get(..., with_for_update=True) acquires the parent row lock, via a new _fetch_latest_run().
  • Added .execution_options(populate_existing=True) so the read bypasses the session identity map and reflects any concurrently committed status.
  • cancel_stuck_once_tasks() now also reads its run per task (fresh), matching the same post-processing read semantics.
  • Removed the now-unused func (window-function) import.

Tests
Added two regression tests (test_reconcile_sees_run_committed_before_finalize, test_cancel_sees_run_committed_before_finalize): a separate transaction commits success on the run, then the recovery path must finalize the parent as completed — not cancelled. Suite is now 19 tests, all green.

Local CI gate results (same commands as CI): make lint clean, make test 12389 passed, new test file 19 passed.

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 Huixin615 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
@WillemJiang

Copy link
Copy Markdown
Collaborator

@Totoro-qaq please take a look at the review comments.

@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from 623505f to 616b6f2 Compare August 30, 2026 05:54
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

Thanks for the precise P2, @Huixin615 — you're right, the previous tests committed success before invoking the recovery method, so the buggy pre-lock batch implementation would also observe the commit and pass. The tests didn't actually enter the race window.

P2 fix

Both race tests now use monkeypatch to intercept _fetch_latest_run at the exact moment the per-task fetch fires. The intercepted function:

  1. Commits status="success" in a separate session (simulating the concurrent completion)
  2. Calls the real _fetch_latest_run(session, task_id) with populate_existing=True
  3. Returns the fresh result

A reverted pre-lock batch implementation never calls _fetch_latest_run — it uses _fetch_latest_runs (batch) instead — so the monkeypatch never fires, the stale cached running row is used, and the test fails (catching the regression). The current post-lock implementation calls _fetch_latest_run, the monkeypatch commits success, and the fresh read picks it up → completed → test passes.

Also fixed a staticmethod() descriptor bug: the monkeypatched function was assigned as a plain function, causing self to be passed as an extra argument. Both the assignment and restoration now use staticmethod() to preserve the correct calling convention.

Verification: lint clean, 19/19 test file passed, full suite 12391 passed / 78 skipped.

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

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.

@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from 616b6f2 to 8757798 Compare August 30, 2026 12:42
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

Thanks for the deep P1, @willem-bd — you're right, I was still finalizing parents for active occurrences. Fixed.

Root cause

_finalise_once_task_from_run() treated a non-terminal run status (queued/launching/running) the same as "no run row" — both fell into the else branch, which applied generic cancellation. But an active occurrence means the run is still in progress; a concurrent completion may have already committed the linked durable run as success. Finalizing the parent from the stale running occurrence status permanently recreates the bug this PR fixes.

Fix

  • _finalise_once_task_from_run() now explicitly checks for active statuses (queued/launching/running) and returns early without touching the parent. Only terminal statuses (success/failed/interrupted/skipped) and truly absent runs trigger finalization.
  • Both cancel_stuck_once_tasks() and reconcile_stuck_once_tasks() detect the no-op (active run) via task_row.updated_at unchanged and skip incrementing their counters.
  • Updated 3 existing tests to expect the parent is left running (not cancelled) for active occurrences.

Verification: lint clean, 19/19 test file passed, full suite 12391 passed / 78 skipped.

Pushed as 87577981 — PR #5035 updated.

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

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.

Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from 8757798 to 23dc63d Compare August 30, 2026 15:41
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough follow-up, @willem-bd. All four items addressed:

[P2] Single-instance path leaves task permanently "running"
Added cancel_stuck_once_tasks to the single-instance poll loop (run_once), mirroring how _reconcile_active_state re-runs both halves every poll for multi-instance. Stuck once-tasks are now retried on every iteration instead of being left in "running" until the next restart.

[Suggestion] Use TERMINAL_RUN_STATUSES / ACTIVE_RUN_STATUSES
Defined local frozenset copies in scheduled_tasks/sql.py with a comment explaining the circular import constraint (scheduled_task_runs.sql → scheduled_tasks/model.py → scheduled_tasks/sql.py → back). Kept in sync manually; a dict-keyed mapping was also considered but the explicit elif chain is clearer for the five-way outcome.

[Nit] Stale docstring in cancel_stuck_once_tasks
Updated to say active occurrences are "left untouched" and only absent runs receive generic cancellation.

[Nit] _fetch_latest_run precondition
Softened from "must be called AFTER the parent task row is locked" to "the caller must not hold a pre-lock snapshot" — the single-instance path doesn't lock the parent row.

Bonus: _finalise_once_task_from_run now returns bool
True if the parent was finalised, False for active/no-op. Both callers use the return value for their counters instead of the fragile updated_at comparison.

Verification: lint clean, 19/19 test file passed, full suite 12391 passed / 78 skipped.

Pushed as 23dc63d0 — PR #5035 updated.

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

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

Comment thread backend/tests/test_scheduler_completion_consistency.py
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/tests/test_scheduler_completion_consistency.py Outdated
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

Addressed the follow-up items from the review (head updated to b34b7154):

  1. Multi-history regressions now control the column that decides "latest". _create_run in the test helper previously let created_at fall back to the repo default, so the recency ordering was never exercised. It now pins created_at (and scheduled_for) explicitly so the offsets in the older/newer regressions actually drive _fetch_latest_run's ordering.

  2. id DESC is no longer used as a recency tie-break. _fetch_latest_run now orders by scheduled_for DESC — a deterministic recency key on the occurrence's schedule position — instead of created_at DESC, id DESC (where id is an opaque run-record string, arbitrary wrt recency).

  3. _finalise_once_task_from_run return annotation matches its contract. It now returns bool (was -> None) since it reports whether the parent was finalized vs left for a later pass.

  4. Docstrings that described the leave-untouched branch as a "generic cancel" are corrected to state the parent is left unchanged (not cancelled) when the latest occurrence is still active.

  5. Constants are now canonical in one place. TERMINAL_RUN_STATUSES / ACTIVE_RUN_STATUSES moved to scheduled_tasks/model.py and imported by both scheduled_tasks/sql.py and scheduled_task_runs/sql.py, removing the local duplication and the stale NOTE about the (real) circular-import workaround.

Also trimmed the PR description to drop the removed DST/croniter test section, matching the 19 tests actually shipped.

Verification: make lint clean; focused suite 36 passed; full make test → 12391 passed, 78 skipped, 0 failures.

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 backend-unit-tests is green? (The prior failure was test_agent_guidance_check.py::test_repository_guidance_stays_below_soft_budgets_and_avoids_doc_indexes, which passes locally and is unrelated to these changes.)

@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

One final cleanup push: 45558b43 changes only _finalise_once_task_from_run()'s return annotation from None to bool, matching its documented contract and both callers.

Verification on this exact change:

  • annotation assertion: RED (None) -> GREEN (bool)
  • uv run pytest tests/test_scheduler_completion_consistency.py -q: 19 passed
  • make lint: ruff check passed; 1,235 files already formatted
  • make test with localhost excluded from the local proxy: 12,388 passed, 81 skipped, 0 failed

One remaining CI/documentation question: the requested one-line backend/AGENTS.md note now makes the synthetic merge exceed the repository's soft guidance budget (28,902 > 28,672 bytes). This was the sole failure in the previous Unit Tests run; the rest of that run passed. I have not relocated the guidance to a different scoped AGENTS.md, because I do not want to choose project guidance ownership as an external contributor. Would you prefer me to fold it into the existing scheduled-task paragraph in backend/AGENTS.md, or drop the note from this PR?

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

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.

Comment thread backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py Outdated
Comment thread backend/app/scheduler/service.py Outdated
Comment thread backend/tests/test_scheduler_completion_consistency.py Outdated
@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from d367e2c to cd75cbb Compare September 9, 2026 11:54
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (0d492530) after #5216 landed; no scheduler logic changed since 50f54f20 (now 99450ebd), head is cd75cbb9.

  • 0022_scheduled_occurrence_seq now chains after canonical 0019_thread_incarnations, so the tree keeps a single head and a database at canonical 0019 validates the fixed floor and then upgrades to 0022 as intended.
  • Main's forward-revision tests assumed canonical 0019 was the local head. cd75cbb9 makes them follow the real head: the canonical-0019 shape is seeded explicitly, upgraded databases are asserted against _get_head_revision(), the simulated 0020 rollback binary's revision set is derived from the ancestors of its own head, and the PostgreSQL restart scenario steps the schema back to canonical 0019 before the rollback binary restarts. The batch-acceptance guard regression keeps main's expectation unchanged.

Validation at cd75cbb9: 290 focused SQLite/PostgreSQL tests (forward-revision compatibility incl. the PostgreSQL restart case, migrations 0019/0021/0022, bootstrap, scheduler ordering/completion/lifespan/trace/guidance), 119 blocking-I/O tests, make lint and ruff format --check pass; the twelve real-runtime scenarios pass with the local replay endpoint. Full offline backend run: 14,595 passed in the sandboxed uv run pytest run plus the 15 browser/fetch cases that need public DNS rerun in a terminal with normal networking (15 passed, user-terminal-baseline15-p4.log); 14,610 executed cases pass, 178 skipped, 3 deselected.

@Huixin615 Huixin615 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

@Huixin615 Thanks, fixed in cbf46fcb: the rolling-forward section now describes the chain through 0022_scheduled_occurrence_seq as the current head and lists it among the revisions the 0020 rollback-floor binary does not know. Documentation only; no code change since cd75cbb9.

Totoro-qaq and others added 16 commits September 11, 2026 03:51
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>
@Totoro-qaq
Totoro-qaq force-pushed the fix/scheduler-completion-consistency branch from cbf46fc to 7f7ccc1 Compare September 10, 2026 20:07
Totoro-qaq and others added 2 commits September 11, 2026 10:58
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>
@Totoro-qaq

Copy link
Copy Markdown
Contributor Author

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 postgresql://...?sslmode=disable URL (normalized to asyncpg, like the 0019 migration tests), and backend/AGENTS.md is back to main's size so the middlewares guidance chain no longer grows. Could the workflows be approved again?

@WillemJiang
WillemJiang merged commit cd0e74e into bytedance:main Sep 11, 2026
16 checks passed
WillemJiang pushed a commit that referenced this pull request Oct 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Restart recovery marks successful scheduled once-tasks as cancelled when the completion hook crashes mid-write

5 participants