BUG-HUNT: [concurrency] Non-reusable action archival is not atomic with plan creation — action stays available if process crashes between transactions #6417

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

Bug Report: [concurrency] — Non-reusable action archival is not atomic with plan creation

Severity Assessment

  • Impact: When a non-reusable action is used, the plan creation and the action archival are performed in two separate transactions. If the process crashes, times out, or receives SIGKILL between them, the action remains in available state even though a plan has already been created from it. This violates the spec invariant that non-reusable actions can only produce a single plan. Additionally, two concurrent use_action() calls for the same non-reusable action could both see it as available and both succeed in creating plans.
  • Likelihood: Low for crash scenario, Medium for concurrent use scenario.
  • Priority: High

Location

  • File: src/cleveragents/application/services/plan_lifecycle_service.py
  • Function: use_action()
  • Lines: ~1085–1095

Description

The use_action() method performs two separate database transactions:

  1. Transaction 1 — Creates the plan:
# lines ~1089–1091
if self._persisted and self.unit_of_work is not None:
    with self.unit_of_work.transaction() as ctx:
        self._persist_plan_create(plan, ctx)  # commits plan to DB
self._plans[plan_id] = plan
  1. Transaction 2 — Archives the action (separately):
# lines ~1093–1095
if not action.reusable:
    self.archive_action(action_name)  # opens a NEW transaction

Inside archive_action():

# lines ~940–964
def archive_action(self, action_name: str) -> Action:
    action = self.get_action(action_name)
    action.state = ActionState.ARCHIVED
    action.updated_at = datetime.now()
    if self._persisted and self.unit_of_work is not None:
        with self.unit_of_work.transaction() as ctx:  # ← SEPARATE transaction
            self._persist_action_update(action, ctx)

Race Condition / Non-Atomicity

Scenario 1 — Process crash:

  1. Process calls use_action("local/one-time-task")
  2. Transaction 1 commits — plan P1 exists in DB
  3. Process crashes (OOM, SIGKILL, host restart)
  4. archive_action() is never called
  5. On restart, local/one-time-task is still available
  6. User can create plan P2 from the same non-reusable action

Scenario 2 — Concurrent calls:

  1. Worker A calls use_action("local/one-time-task")
  2. Worker B calls use_action("local/one-time-task") concurrently
  3. Both A and B read the action as AVAILABLE (check at line ~1009)
  4. Both A and B persist their plans in separate Transaction 1 calls
  5. Both A and B call archive_action() — last one wins, but two plans exist
  6. Non-reusable invariant is violated: two plans were created

Expected Behavior

For non-reusable actions, the plan creation and action archival should be atomic — either both succeed or both fail. The action should transition to archived in the same transaction as the plan creation.

Actual Behavior

The plan is created in one transaction; the action archival happens in a separate transaction. Any failure between these two operations leaves the system in an inconsistent state.

Suggested Fix

Combine plan creation and action archival into a single transaction in use_action():

# Use a single transaction for both operations
if self._persisted and self.unit_of_work is not None:
    with self.unit_of_work.transaction() as ctx:
        self._persist_plan_create(plan, ctx)
        if not action.reusable:
            action.state = ActionState.ARCHIVED
            action.updated_at = datetime.now()
            self._persist_action_update(action, ctx)
else:
    # In-memory mode: update in-memory state
    if not action.reusable:
        action.state = ActionState.ARCHIVED
        self._actions[str(action.namespaced_name)] = action
self._plans[plan_id] = plan

This ensures atomicity: if the transaction fails, neither the plan nor the archival is persisted.

Category

concurrency

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

## Bug Report: [concurrency] — Non-reusable action archival is not atomic with plan creation ### Severity Assessment - **Impact**: When a non-reusable action is used, the plan creation and the action archival are performed in **two separate transactions**. If the process crashes, times out, or receives SIGKILL between them, the action remains in `available` state even though a plan has already been created from it. This violates the spec invariant that non-reusable actions can only produce a single plan. Additionally, two concurrent `use_action()` calls for the same non-reusable action could both see it as `available` and both succeed in creating plans. - **Likelihood**: Low for crash scenario, Medium for concurrent use scenario. - **Priority**: High ### Location - **File**: `src/cleveragents/application/services/plan_lifecycle_service.py` - **Function**: `use_action()` - **Lines**: ~1085–1095 ### Description The `use_action()` method performs two separate database transactions: 1. **Transaction 1** — Creates the plan: ```python # lines ~1089–1091 if self._persisted and self.unit_of_work is not None: with self.unit_of_work.transaction() as ctx: self._persist_plan_create(plan, ctx) # commits plan to DB self._plans[plan_id] = plan ``` 2. **Transaction 2** — Archives the action (separately): ```python # lines ~1093–1095 if not action.reusable: self.archive_action(action_name) # opens a NEW transaction ``` Inside `archive_action()`: ```python # lines ~940–964 def archive_action(self, action_name: str) -> Action: action = self.get_action(action_name) action.state = ActionState.ARCHIVED action.updated_at = datetime.now() if self._persisted and self.unit_of_work is not None: with self.unit_of_work.transaction() as ctx: # ← SEPARATE transaction self._persist_action_update(action, ctx) ``` ### Race Condition / Non-Atomicity **Scenario 1 — Process crash:** 1. Process calls `use_action("local/one-time-task")` 2. Transaction 1 commits — plan `P1` exists in DB 3. Process crashes (OOM, SIGKILL, host restart) 4. `archive_action()` is never called 5. On restart, `local/one-time-task` is still `available` 6. User can create plan `P2` from the same non-reusable action **Scenario 2 — Concurrent calls:** 1. Worker A calls `use_action("local/one-time-task")` 2. Worker B calls `use_action("local/one-time-task")` concurrently 3. Both A and B read the action as `AVAILABLE` (check at line ~1009) 4. Both A and B persist their plans in separate Transaction 1 calls 5. Both A and B call `archive_action()` — last one wins, but two plans exist 6. Non-reusable invariant is violated: two plans were created ### Expected Behavior For non-reusable actions, the plan creation and action archival should be atomic — either both succeed or both fail. The action should transition to `archived` in the **same transaction** as the plan creation. ### Actual Behavior The plan is created in one transaction; the action archival happens in a separate transaction. Any failure between these two operations leaves the system in an inconsistent state. ### Suggested Fix Combine plan creation and action archival into a single transaction in `use_action()`: ```python # Use a single transaction for both operations if self._persisted and self.unit_of_work is not None: with self.unit_of_work.transaction() as ctx: self._persist_plan_create(plan, ctx) if not action.reusable: action.state = ActionState.ARCHIVED action.updated_at = datetime.now() self._persist_action_update(action, ctx) else: # In-memory mode: update in-memory state if not action.reusable: action.state = ActionState.ARCHIVED self._actions[str(action.namespaced_name)] = action self._plans[plan_id] = plan ``` This ensures atomicity: if the transaction fails, neither the plan nor the archival is persisted. ### Category concurrency ### 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
HAL9000 added this to the v3.2.0 milestone 2026-04-09 21:09:07 +00:00
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#6417
No description provided.