diff --git a/src/task_scheduler.py b/src/task_scheduler.py index c94c19de3..df515ebea 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -687,6 +687,12 @@ class TaskScheduler: db = SessionLocal() try: now = _utcnow() + foreground_active = False + try: + from src.interactive_gate import has_foreground_activity + foreground_active = has_foreground_activity() + except Exception: + foreground_active = False async with self._executing_lock: # Snapshot under the lock so we don't race with mid-iteration adds. executing_snapshot = set(self._executing) @@ -700,8 +706,13 @@ class TaskScheduler: for task in due: if task.id in self._executing: continue + if foreground_active: + task.next_run = now + timedelta(minutes=15) + continue self._executing.add(task.id) to_dispatch.append(task.id) + if foreground_active and due: + db.commit() for task_id in to_dispatch: asyncio.create_task(self._execute_task(task_id)) finally: @@ -754,6 +765,7 @@ class TaskScheduler: # If cancellation happens while queued behind the semaphore, # _execute_task_locked never runs and cannot update the Activity row. self._mark_run_aborted(task_id, run_id) + self._defer_immediately_due_task(task_id, delay=timedelta(minutes=15)) raise finally: handle = self._task_handles.get(task_id) @@ -763,6 +775,28 @@ class TaskScheduler: async with self._executing_lock: self._executing.discard(task_id) + def _defer_immediately_due_task(self, task_id: str, *, delay: timedelta): + """A queued task can be cancelled before _execute_task_locked gets a DB + handle. If its next_run stays in the past, the scheduler dispatches it + again on the next tick and spams aborted Activity rows.""" + try: + from core.database import SessionLocal, ScheduledTask + db = SessionLocal() + try: + task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() + if ( + task + and task.status == "active" + and task.next_run is not None + and task.next_run <= _utcnow() + ): + task.next_run = _utcnow() + delay + db.commit() + finally: + db.close() + except Exception: + logger.debug("Failed to defer cancelled queued task %s", task_id, exc_info=True) + async def _execute_task_locked( self, task_id: str, diff --git a/static/app.js b/static/app.js index be7d3f863..5888f517c 100644 --- a/static/app.js +++ b/static/app.js @@ -22,7 +22,7 @@ import memoryModule from './js/memory.js'; import voiceRecorderModule from './js/voiceRecorder.js'; import censorModule from './js/censor.js'; import galleryModule from './js/gallery.js'; -import tasksModule from './js/tasks.js'; +import tasksModule from './js/tasks.js?v=20260630tasksactivity'; import calendarModule from './js/calendar.js'; import notesModule from './js/notes.js'; import adminModule from './js/admin.js'; diff --git a/static/index.html b/static/index.html index 4515a4fad..d19fffa5f 100644 --- a/static/index.html +++ b/static/index.html @@ -219,8 +219,8 @@ }, { once: true }); })(); - - + + @@ -2512,7 +2512,7 @@ - + diff --git a/static/js/tasks.js b/static/js/tasks.js index bde11d9e2..4172a429b 100644 --- a/static/js/tasks.js +++ b/static/js/tasks.js @@ -1948,7 +1948,7 @@ async function _renderActivityView() { const body = modal?.querySelector('.modal-body'); if (!body) return; body.innerHTML = ` -
+

Activity

@@ -1958,7 +1958,7 @@ async function _renderActivityView() {
-
+
`; @@ -2123,6 +2123,7 @@ let _activityHasMore = false; function _stackActivityEntries(entries) { const out = []; const byKey = new Map(); + const maxStack = 8; const hourBucket = (ts) => { const d = ts ? new Date(ts) : null; if (!d || Number.isNaN(d.getTime())) return ''; @@ -2150,7 +2151,12 @@ function _stackActivityEntries(entries) { /^Email\b/i.test(entry.taskName || '') ? hourBucket(entry.ts) : '', ].join('\u0001'); const existing = byKey.get(key); - if (existing && entry.status !== 'running' && entry.status !== 'queued') { + if ( + existing + && entry.status !== 'running' + && entry.status !== 'queued' + && (existing.repeatCount || 1) < maxStack + ) { existing.repeatCount = (existing.repeatCount || 1) + 1; continue; } diff --git a/static/style.css b/static/style.css index b3f10bb4f..faf725597 100644 --- a/static/style.css +++ b/static/style.css @@ -11063,7 +11063,7 @@ textarea.memory-add-input { text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 6px; - border-radius: 3px; + border-radius: 999px; flex-shrink: 0; cursor: pointer; border: 1px solid transparent; @@ -23305,6 +23305,14 @@ body:not(.welcome-ready) #welcome-screen { top: 8px; } .tasks-modal-content { max-width: 600px; width: min(600px, 92vw); background: var(--bg); font-size: 12px; } +.tasks-activity-card, +.tasks-activity-list { + min-height: 0; +} +.tasks-activity-list { + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} /* Tasks tabs reuse the .memory-tab look. The Brain window's tab bar is full-bleed (its underline spans the whole modal width). The Tasks bar is a diff --git a/static/sw.js b/static/sw.js index 54ee41816..071e6a3bc 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v328'; +const CACHE_NAME = 'odysseus-v329'; // Core shell precached on install so repeat opens are instant without any // network wait. Keep this list in sync with the