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:
2026-05-13 12:11:41 -04:00
parent 362ead7f0d
commit 5419330633
2 changed files with 194 additions and 0 deletions
@@ -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
+108
View File
@@ -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()