Skip to content

fix(scheduling): make ScheduledAgentTask start/stop atomic state transitions (issue #215) - #370

Open
konlue wants to merge 1 commit into
agentic-ai-java:mainfrom
konlue:fix/issue215-scheduled-task-atomic-lifecycle
Open

konlue wants to merge 1 commit into
agentic-ai-java:mainfrom
konlue:fix/issue215-scheduled-task-atomic-lifecycle

Conversation

@konlue

@konlue konlue commented Nov 15, 2026

Copy link
Copy Markdown
Contributor

Describe what this PR does / why we need it

Issue #215 reports that ScheduledAgentTask.start/stop compose their lifecycle from two independent volatile flags with no atomic transition. This PR replaces the flag pair with a single CAS-able lifecycle state and closes all four windows it exposes, with deterministic latch-driven regression tests.

Root cause

Four concrete defects fall out of the flag pair. Two concurrent starts can both pass the started guard and each publish a schedule, leaving two live schedulings for one task. A stop racing with an in-flight start can finish before the future is published, so the late future is never cancelled and keeps triggering. A stop followed by a start succeeds: stopped plays no role in the start guard, resurrecting a stopped task. And a start whose scheduling throws leaves the task registered forever, since the registration happens in the constructor but nothing releases it on failure.

The fix

The task now holds one AtomicReference over a package-private ScheduleState (NEW, STARTING, RUNNING, STOPPING, STOPPED, FAILED). start() claims NEW → STARTING up front, so a concurrent or repeated start is rejected while the first is still publishing the schedule. After the schedule is published, STARTING → RUNNING must win; if stop() took the state during the window, the just-published future is cancelled instead of left live and the start fails. A scheduling failure moves to FAILED, releases the registration, and propagates the original exception, so a failed start strands nothing. stop() funnels through a single-winner transition to STOPPING — concurrent and repeated stops are no-ops — then cancels the future, unregisters, notifies, and lands on STOPPED. The stop semantics are unchanged: cancel(false) only, so in-flight executions are still not interrupted. No new listener events and no public API growth; isStarted/isStopped keep their previous observable semantics (started history is retained after stop).

Testing

The new ScheduledAgentTaskAtomicStartStopTest gates the scheduler publication with latches for deterministic interleaving: a first start blocked inside the publication window makes a concurrent second start fail with exactly one schedule published (red before: two schedules, no exception); a start after stop is rejected with no new schedule (red before: it scheduled again); a start whose scheduling throws unregisters and propagates (red before: registration left behind); a stop during the STARTING window cancels the late-published future and ends STOPPED (red before: the future stayed live and start succeeded); and the full state walk NEW → STARTING → RUNNING → STOPPING → STOPPED is asserted through a package-private observation point. The scheduling package is 19/19 green with the #214/#216 regressions untouched, checkstyle and spring-javaformat are clean. The module's 8 pre-existing failures on this baseline (skills prompt template, file store Windows path, prompt template line endings) are unrelated and unchanged.

Fixes #215

…sitions (issue agentic-ai-java#215)

start/stop composed their lifecycle from independent volatile
started/stopped flags, so the transitions were not atomic: two
concurrent starts could both pass the guard and publish two schedules,
a stop racing with the schedule publication could miss the future
published afterwards, a stop followed by start resurrected the stopped
task, and a failed start left the registration behind.

The lifecycle is now a single CAS-able state (NEW, STARTING, RUNNING,
STOPPING, STOPPED, FAILED). start() claims NEW->STARTING up front, so a
second start is rejected while the first is still publishing; a stop
winning the STARTING window makes the late future cancelled instead of
live; a scheduling failure releases the registration and lands in
FAILED. stop() is idempotent through a single winner transition. The
derived isStarted/isStopped keep their previous observable semantics,
and all existing lifecycle/ownership tests pass unchanged.

Deterministic interleaving tests gate the scheduler publication with
latches: double start schedules once, start after stop is rejected,
failed start unregisters, stop during start cancels the late future.
@konlue
konlue force-pushed the fix/issue215-scheduled-task-atomic-lifecycle branch from 00523e4 to 31be3f3 Compare November 15, 2026 03:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][OPT-24] 为定时任务 start/stop 建立原子生命周期边界

1 participant