BUG-HUNT: [boundary] _lifecycle_apply_with_id dereferences result of service.get_plan() without None check — AttributeError crash if plan is deleted mid-transition #6463

Open
opened 2026-04-09 21:06:38 +00:00 by HAL9000 · 0 comments
Owner

Bug Report: [boundary] — Missing None Check After service.get_plan() in _lifecycle_apply_with_id

Severity Assessment

  • Impact: If service.get_plan(plan_id) returns None (e.g., plan was concurrently deleted, DB corruption, or race condition), the code immediately accesses .phase and .state on the None return value, causing an unhandled AttributeError: 'NoneType' object has no attribute 'phase'. This crashes the CLI with a raw traceback instead of a friendly "plan not found" message.
  • Likelihood: Low in normal usage, but real in: concurrent executions of apply, test environments that clean up DB between calls, race conditions with the cancel command.
  • Priority: Medium

Location

  • File: src/cleveragents/cli/commands/plan.py
  • Function/Class: _lifecycle_apply_with_id()
  • Lines: 1104–1113

Description

_lifecycle_apply_with_id() calls service.get_plan(plan_id) twice in succession (lines 1104 and 1108) to check intermediate phase/state after each service transition call. Both return values are used immediately without a None check:

current = service.get_plan(plan_id)
if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED:
    service.start_apply(plan_id)

current = service.get_plan(plan_id)
if (
    current.phase == PlanPhase.APPLY       # ← AttributeError if current is None
    and current.state == ProcessingState.PROCESSING
):
    service.complete_apply(plan_id)

Note that the initial get_plan() call (line 1081) correctly checks for None:

pre_plan = service.get_plan(plan_id)
if pre_plan is None:
    console.print(f"[red]Plan '{plan_id}' not found.[/red]")
    raise typer.Abort()

But the intermediate calls at lines 1104 and 1108 do not perform this guard.

service.get_plan() returns Plan | None — it is documented to return None when the plan does not exist.

Evidence

# src/cleveragents/cli/commands/plan.py lines 1100–1116
        # Determine current phase and drive through apply
        if (
            pre_plan.phase == PlanPhase.EXECUTE
            and pre_plan.state == ProcessingState.COMPLETE
        ):
            # Transition Execute/complete -> Apply/queued
            service.apply_plan(plan_id)

        current = service.get_plan(plan_id)   # ← may return None
        if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED:  # ← CRASH if None
            service.start_apply(plan_id)

        current = service.get_plan(plan_id)   # ← may return None
        if (
            current.phase == PlanPhase.APPLY    # ← CRASH if None
            and current.state == ProcessingState.PROCESSING
        ):
            service.complete_apply(plan_id)

Expected Behavior

If service.get_plan() returns None at any intermediate step, the function should print a clear error message and abort gracefully:

Plan '<id>' not found after state transition.

Actual Behavior

An AttributeError: 'NoneType' object has no attribute 'phase' is raised, producing a raw traceback visible to the user.

Suggested Fix

Add None guards after each intermediate get_plan() call:

current = service.get_plan(plan_id)
if current is None:
    console.print(f"[red]Plan '{plan_id}' disappeared during apply transition.[/red]")
    raise typer.Abort()
if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED:
    service.start_apply(plan_id)

current = service.get_plan(plan_id)
if current is None:
    console.print(f"[red]Plan '{plan_id}' disappeared during apply transition.[/red]")
    raise typer.Abort()
if (
    current.phase == PlanPhase.APPLY
    and current.state == ProcessingState.PROCESSING
):
    service.complete_apply(plan_id)

Category

boundary

TDD Note

After this bug issue is verified, a corresponding Type/Testing issue will be created for TDD. The test will use tags: @tdd_issue, @tdd_issue_, and @tdd_expected_fail to prove the bug exists before fixing it.


Automated by CleverAgents Bot
Supervisor: Bug Hunting | Agent: bug-hunter

## Bug Report: [boundary] — Missing None Check After `service.get_plan()` in `_lifecycle_apply_with_id` ### Severity Assessment - **Impact**: If `service.get_plan(plan_id)` returns `None` (e.g., plan was concurrently deleted, DB corruption, or race condition), the code immediately accesses `.phase` and `.state` on the `None` return value, causing an unhandled `AttributeError: 'NoneType' object has no attribute 'phase'`. This crashes the CLI with a raw traceback instead of a friendly "plan not found" message. - **Likelihood**: Low in normal usage, but real in: concurrent executions of `apply`, test environments that clean up DB between calls, race conditions with the `cancel` command. - **Priority**: Medium ### Location - **File**: `src/cleveragents/cli/commands/plan.py` - **Function/Class**: `_lifecycle_apply_with_id()` - **Lines**: 1104–1113 ### Description `_lifecycle_apply_with_id()` calls `service.get_plan(plan_id)` **twice** in succession (lines 1104 and 1108) to check intermediate phase/state after each service transition call. Both return values are used immediately without a `None` check: ```python current = service.get_plan(plan_id) if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED: service.start_apply(plan_id) current = service.get_plan(plan_id) if ( current.phase == PlanPhase.APPLY # ← AttributeError if current is None and current.state == ProcessingState.PROCESSING ): service.complete_apply(plan_id) ``` Note that the **initial** `get_plan()` call (line 1081) correctly checks for `None`: ```python pre_plan = service.get_plan(plan_id) if pre_plan is None: console.print(f"[red]Plan '{plan_id}' not found.[/red]") raise typer.Abort() ``` But the **intermediate** calls at lines 1104 and 1108 do not perform this guard. `service.get_plan()` returns `Plan | None` — it **is** documented to return `None` when the plan does not exist. ### Evidence ```python # src/cleveragents/cli/commands/plan.py lines 1100–1116 # Determine current phase and drive through apply if ( pre_plan.phase == PlanPhase.EXECUTE and pre_plan.state == ProcessingState.COMPLETE ): # Transition Execute/complete -> Apply/queued service.apply_plan(plan_id) current = service.get_plan(plan_id) # ← may return None if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED: # ← CRASH if None service.start_apply(plan_id) current = service.get_plan(plan_id) # ← may return None if ( current.phase == PlanPhase.APPLY # ← CRASH if None and current.state == ProcessingState.PROCESSING ): service.complete_apply(plan_id) ``` ### Expected Behavior If `service.get_plan()` returns `None` at any intermediate step, the function should print a clear error message and abort gracefully: ``` Plan '<id>' not found after state transition. ``` ### Actual Behavior An `AttributeError: 'NoneType' object has no attribute 'phase'` is raised, producing a raw traceback visible to the user. ### Suggested Fix Add None guards after each intermediate `get_plan()` call: ```python current = service.get_plan(plan_id) if current is None: console.print(f"[red]Plan '{plan_id}' disappeared during apply transition.[/red]") raise typer.Abort() if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED: service.start_apply(plan_id) current = service.get_plan(plan_id) if current is None: console.print(f"[red]Plan '{plan_id}' disappeared during apply transition.[/red]") raise typer.Abort() if ( current.phase == PlanPhase.APPLY and current.state == ProcessingState.PROCESSING ): service.complete_apply(plan_id) ``` ### Category boundary ### TDD Note After this bug issue is verified, a corresponding Type/Testing issue will be created for TDD. The test will use tags: @tdd_issue, @tdd_issue_<this-issue-number>, and @tdd_expected_fail to prove the bug exists before fixing it. --- **Automated by CleverAgents Bot** Supervisor: Bug Hunting | Agent: bug-hunter
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cleveragents/cleveragents-core#6463
No description provided.