feat(consolidation): debounced gate for task body consolidation
New services/consolidation.py module with maybe_consolidate() — the debounced trigger gate. Two reasons: - log_added: gated by DEFAULT_LOG_THRESHOLD (3) counted since the task's consolidated_at timestamp. - task_closed: bypasses the count gate; fires whenever status flips to done/cancelled. Both reasons gated by the auto_consolidate_tasks user setting (default on). Per-task asyncio.Lock prevents two simultaneous passes for the same task. consolidate_task is a stub here — full implementation in the next commit.
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
"""Background task-body consolidation pipeline.
|
||||||
|
|
||||||
|
Reads a task's description (user goal) + work logs (chronological) and writes
|
||||||
|
a 1-3 paragraph summary into Note.body via the background model. Triggered by
|
||||||
|
log accumulation (debounced), status transitions to terminal states, and a
|
||||||
|
manual API endpoint.
|
||||||
|
|
||||||
|
Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from fabledassistant.models import async_session
|
||||||
|
from fabledassistant.models.note import Note
|
||||||
|
from fabledassistant.models.task_log import TaskLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Trigger thresholds. Tunable as constants; could be promoted to env vars if
|
||||||
|
# the defaults prove wrong in practice.
|
||||||
|
DEFAULT_LOG_THRESHOLD = 3
|
||||||
|
MAX_LOGS_FOR_PROMPT = 50
|
||||||
|
MAX_PROMPT_INPUT_CHARS = 8000
|
||||||
|
|
||||||
|
# Per-task asyncio locks to prevent two simultaneous consolidations of the
|
||||||
|
# same task. Single-process; no cross-process coordination needed.
|
||||||
|
_locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||||
|
|
||||||
|
|
||||||
|
async def _logs_since_last_consolidation(user_id: int, task_id: int) -> int:
|
||||||
|
"""Count work logs that arrived after the most recent consolidation pass.
|
||||||
|
|
||||||
|
Returns 0 if the task doesn't exist. Returns the total log count when
|
||||||
|
consolidated_at is NULL (i.e. never consolidated).
|
||||||
|
"""
|
||||||
|
async with async_session() as session:
|
||||||
|
task = (
|
||||||
|
await session.execute(
|
||||||
|
select(Note).where(Note.id == task_id, Note.user_id == user_id)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if task is None:
|
||||||
|
return 0
|
||||||
|
stmt = select(func.count(TaskLog.id)).where(
|
||||||
|
TaskLog.task_id == task_id, TaskLog.user_id == user_id,
|
||||||
|
)
|
||||||
|
if task.consolidated_at is not None:
|
||||||
|
stmt = stmt.where(TaskLog.created_at > task.consolidated_at)
|
||||||
|
return int((await session.execute(stmt)).scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _auto_consolidate_enabled(user_id: int) -> bool:
|
||||||
|
"""User-level setting; default true. Manual endpoint bypasses this."""
|
||||||
|
from fabledassistant.services.settings import get_setting
|
||||||
|
val = await get_setting(user_id, "auto_consolidate_tasks", "true")
|
||||||
|
return str(val).lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_consolidate(user_id: int, task_id: int, *, reason: str) -> None:
|
||||||
|
"""Debounced gate. Decides whether to schedule a consolidate_task pass.
|
||||||
|
|
||||||
|
reason='log_added' — gated by log count >= DEFAULT_LOG_THRESHOLD
|
||||||
|
reason='task_closed' — proceeds unconditionally (subject to setting)
|
||||||
|
"""
|
||||||
|
if not await _auto_consolidate_enabled(user_id):
|
||||||
|
return
|
||||||
|
if reason == "log_added":
|
||||||
|
n = await _logs_since_last_consolidation(user_id, task_id)
|
||||||
|
if n < DEFAULT_LOG_THRESHOLD:
|
||||||
|
return
|
||||||
|
elif reason != "task_closed":
|
||||||
|
logger.warning("maybe_consolidate: unknown reason %r", reason)
|
||||||
|
return
|
||||||
|
# Fire-and-forget; consolidate_task handles its own errors.
|
||||||
|
asyncio.create_task(consolidate_task(user_id, task_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def consolidate_task(user_id: int, task_id: int) -> None:
|
||||||
|
"""Stub — full implementation in the next task in the plan."""
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests for the task-body consolidation pipeline (gate + full pass).
|
||||||
|
|
||||||
|
Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gate logic ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maybe_consolidate_below_threshold_does_not_fire():
|
||||||
|
"""log_added with only 2 logs since last pass → no consolidation."""
|
||||||
|
from fabledassistant.services import consolidation
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
consolidation, "_logs_since_last_consolidation",
|
||||||
|
new=AsyncMock(return_value=2),
|
||||||
|
), patch.object(
|
||||||
|
consolidation, "consolidate_task", new=AsyncMock(),
|
||||||
|
) as mock_consolidate, patch.object(
|
||||||
|
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||||
|
):
|
||||||
|
await consolidation.maybe_consolidate(
|
||||||
|
user_id=1, task_id=42, reason="log_added",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_consolidate.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maybe_consolidate_at_threshold_fires():
|
||||||
|
"""log_added with N logs since last pass → consolidation scheduled."""
|
||||||
|
from fabledassistant.services import consolidation
|
||||||
|
|
||||||
|
# asyncio.create_task is the actual scheduler; replace it so the test
|
||||||
|
# doesn't need an event-loop background task to settle.
|
||||||
|
with patch.object(
|
||||||
|
consolidation, "_logs_since_last_consolidation",
|
||||||
|
new=AsyncMock(return_value=3),
|
||||||
|
), patch.object(
|
||||||
|
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||||
|
) as mock_create_task:
|
||||||
|
await consolidation.maybe_consolidate(
|
||||||
|
user_id=1, task_id=42, reason="log_added",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_create_task.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maybe_consolidate_task_closed_always_fires():
|
||||||
|
"""task_closed bypasses the log-count gate."""
|
||||||
|
from fabledassistant.services import consolidation
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
consolidation, "_logs_since_last_consolidation",
|
||||||
|
new=AsyncMock(return_value=0),
|
||||||
|
), patch.object(
|
||||||
|
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||||
|
) as mock_create_task:
|
||||||
|
await consolidation.maybe_consolidate(
|
||||||
|
user_id=1, task_id=42, reason="task_closed",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_create_task.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maybe_consolidate_setting_off_blocks_both_reasons():
|
||||||
|
"""auto_consolidate_tasks=false → neither trigger fires."""
|
||||||
|
from fabledassistant.services import consolidation
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
consolidation, "_logs_since_last_consolidation",
|
||||||
|
new=AsyncMock(return_value=5),
|
||||||
|
), patch.object(
|
||||||
|
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=False),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||||
|
) as mock_create_task:
|
||||||
|
await consolidation.maybe_consolidate(
|
||||||
|
user_id=1, task_id=42, reason="log_added",
|
||||||
|
)
|
||||||
|
await consolidation.maybe_consolidate(
|
||||||
|
user_id=1, task_id=42, reason="task_closed",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_create_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maybe_consolidate_unknown_reason_is_noop():
|
||||||
|
"""Unknown reasons get logged and skipped — not raised."""
|
||||||
|
from fabledassistant.services import consolidation
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
consolidation, "_logs_since_last_consolidation",
|
||||||
|
new=AsyncMock(return_value=99),
|
||||||
|
), patch.object(
|
||||||
|
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||||
|
) as mock_create_task:
|
||||||
|
await consolidation.maybe_consolidate(
|
||||||
|
user_id=1, task_id=42, reason="some_other_reason",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_create_task.assert_not_called()
|
||||||
Reference in New Issue
Block a user