Scheduler: hang-proof event loop — per-event cap, watchdog, guarded dispose #25

Merged
bjoern merged 1 commit from fix/scheduler-hang-watchdog into main 2026-07-13 12:26:23 +02:00
Member

Problem

After the 2026-07-13 03:00 Uber-Ich run logged runUberIch end at 03:17, no timer ever fired again — nightly research, 6:30 morning greeting, 7:00 mail check all silently dead, with zero log output explaining why.

Root cause chain:

  1. Scheduler processes due events sequentially, awaiting each handler behind a _processing flag. Errors are caught — but a handler that simply never completes leaves _processing == true forever, and every subsequent tick returns immediately. The scheduler dies silently.
  2. The handler that hung: runUberIch's finally block awaits mail.backend.dispose(). ImapSmtpBackend.dispose() awaited an IMAP logout(), which waits for a server reply — on a connection gone stale during the 17-minute run, that reply never comes, and a hang is not an error so its catch never engaged. (Evidence: the SchedulerService "Uber-Ich completed" line that immediately follows persistUserMessages never appeared.)

Changes

Scheduler (angela_core):

  • Hard per-event cap, default 2 h (constructor-configurable; generous so long legitimate runs are never cut). On expiry: SEVERE log, the handler is abandoned, the loop continues — one stuck event can no longer kill all future timers.
  • Watchdog: while a handler runs, a WARNING every 5 min (Event X still running after Nm — subsequent events are queued behind it). A slow or stuck event is now visible instead of silent.

AgentRunner (angela_core):

  • All tool-backend disposal in all four run modes (chat, preview, timer, Uber-Ich) now goes through _disposeQuietly: 30 s cap, failures/timeouts logged as WARNING and swallowed. Cleanup can never wedge or fail a run again, regardless of backend behavior. This also fixes that the previously-bare sync disposes (ha, plex) could throw out of the finally and clobber the run result.

Tests: new scheduler_test.dart — a hung handler and a throwing handler must not block subsequent events (uses tight timeouts against an in-memory DB).

The backend-level fix (capped IMAP logout()/SMTP quit() with force-disconnect() fallback, capped puppeteer close() with SIGKILL fallback) is openrouter_dart PR #6. This PR is independent of it (no submodule bump included); the bump follows once #6 is merged. Belt and suspenders: backends won't hang, and even if something else ever does, the scheduler survives and says so in the log.

dart analyze clean (pre-existing infos only), full angela_core suite passes (97 tests), angela_server analyze clean.

🤖 Generated with Claude Code

## Problem After the 2026-07-13 03:00 Uber-Ich run logged `runUberIch end` at 03:17, **no timer ever fired again** — nightly research, 6:30 morning greeting, 7:00 mail check all silently dead, with zero log output explaining why. Root cause chain: 1. `Scheduler` processes due events **sequentially**, awaiting each handler behind a `_processing` flag. Errors are caught — but a handler that simply *never completes* leaves `_processing == true` forever, and every subsequent tick returns immediately. The scheduler dies silently. 2. The handler that hung: `runUberIch`'s `finally` block awaits `mail.backend.dispose()`. `ImapSmtpBackend.dispose()` awaited an IMAP `logout()`, which waits for a server reply — on a connection gone stale during the 17-minute run, that reply never comes, and a hang is not an error so its `catch` never engaged. (Evidence: the `SchedulerService` "Uber-Ich completed" line that immediately follows `persistUserMessages` never appeared.) ## Changes **`Scheduler` (angela_core):** - Hard per-event cap, default **2 h** (constructor-configurable; generous so long legitimate runs are never cut). On expiry: SEVERE log, the handler is abandoned, the loop continues — one stuck event can no longer kill all future timers. - Watchdog: while a handler runs, a WARNING every 5 min (`Event X still running after Nm — subsequent events are queued behind it`). A slow or stuck event is now *visible* instead of silent. **`AgentRunner` (angela_core):** - All tool-backend disposal in all four run modes (chat, preview, timer, Uber-Ich) now goes through `_disposeQuietly`: 30 s cap, failures/timeouts logged as WARNING and swallowed. Cleanup can never wedge or fail a run again, regardless of backend behavior. This also fixes that the previously-bare sync disposes (`ha`, `plex`) could throw out of the `finally` and clobber the run result. **Tests:** new `scheduler_test.dart` — a hung handler and a throwing handler must not block subsequent events (uses tight timeouts against an in-memory DB). ## Related The backend-level fix (capped IMAP `logout()`/SMTP `quit()` with force-`disconnect()` fallback, capped puppeteer `close()` with SIGKILL fallback) is [openrouter_dart PR #6](https://git.kagaku.eu/TeamAI/openrouter_dart/pulls/6). This PR is independent of it (no submodule bump included); the bump follows once #6 is merged. Belt and suspenders: backends won't hang, and even if something else ever does, the scheduler survives and says so in the log. `dart analyze` clean (pre-existing infos only), full `angela_core` suite passes (97 tests), `angela_server` analyze clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Scheduler: hang-proof event loop — per-event cap, watchdog, guarded dispose
All checks were successful
Test / test (apps/angela_server) (pull_request) Successful in 34s
Test / test (packages/angela_api) (pull_request) Successful in 31s
Test / test (packages/angela_core) (pull_request) Successful in 40s
c72e798b56
The scheduler processes due events sequentially behind a _processing
flag; a handler that never completed silently stopped ALL future events
with zero log output. That happened in production: an Uber-Ich run's
finally block awaited ImapSmtpBackend.dispose(), whose logout() waited
forever on a stale IMAP connection — every timer after 03:17 was dead.

- Scheduler: hard per-event cap (default 2h, generous for long runs);
  on expiry the handler is abandoned with a SEVERE log and the loop
  continues. A watchdog logs every 5min while a handler is still
  running, so a slow or stuck event is visible instead of silent.
- AgentRunner: all tool-backend disposal now goes through
  _disposeQuietly (30s cap, failures logged as WARNING) in all four
  run modes, so cleanup can never wedge or fail a run.
- Tests: hung and throwing handlers must not block subsequent events.

The backend-level fix (capped IMAP logout / puppeteer close with
force-close fallbacks) is openrouter_dart PR #6; the submodule bump
follows once that is merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Coverage: apps/angela_server

File Line coverage
lib/config.dart 5.3% (1 of 19)
lib/handlers/timer_handler.dart 44.8% (47 of 105)
lib/server_context.dart 100.0% (28 of 28)
lib/services/user_message_persistence.dart 100.0% (10 of 10)
lib/util/json_helpers.dart 53.8% (7 of 13)
lib/util/request_parser.dart 35.7% (5 of 14)
lib/services/conversation_activity_tracker.dart 15.4% (2 of 13)
lib/handlers/status_handler.dart 95.5% (21 of 22)
lib/handlers/conversation_handler.dart 36.4% (16 of 44)

Total: 51.1% (137 of 268)

<!-- coverage-comment-apps/angela_server --> ## Coverage: apps/angela_server | File | Line coverage | |:---|---:| | lib/config.dart | 5.3% (1 of 19) | | lib/handlers/timer_handler.dart | 44.8% (47 of 105) | | lib/server_context.dart | 100.0% (28 of 28) | | lib/services/user_message_persistence.dart | 100.0% (10 of 10) | | lib/util/json_helpers.dart | 53.8% (7 of 13) | | lib/util/request_parser.dart | 35.7% (5 of 14) | | lib/services/conversation_activity_tracker.dart | 15.4% (2 of 13) | | lib/handlers/status_handler.dart | 95.5% (21 of 22) | | lib/handlers/conversation_handler.dart | 36.4% (16 of 44) | **Total: 51.1% (137 of 268)**

Coverage: packages/angela_api

File Line coverage
lib/src/routes.dart 0.0% (0 of 75)
lib/src/dto/agenda_dto.dart 0.0% (0 of 50)
lib/src/dto/assistant_dto.dart 0.0% (0 of 79)
lib/src/dto/character_alias_dto.dart 0.0% (0 of 19)
lib/src/dto/chat_audio.dart 0.0% (0 of 7)
lib/src/dto/enums.dart 0.0% (0 of 15)
lib/src/dto/chat_dto.dart 0.0% (0 of 14)
lib/src/dto/chat_image.dart 0.0% (0 of 7)
lib/src/dto/conversation_dto.dart 0.0% (0 of 15)
lib/src/dto/message_metadata.dart 0.0% (0 of 14)
lib/src/dto/recollection_dto.dart 0.0% (0 of 28)
lib/src/dto/persona_dto.dart 0.0% (0 of 10)
lib/src/dto/timer_dto.dart 0.0% (0 of 54)
lib/src/dto/memory_dto.dart 0.0% (0 of 5)
lib/src/dto/response_dto.dart 0.0% (0 of 12)
lib/src/dto/status_dto.dart 100.0% (18 of 18)
lib/src/dto/todo_item_dto.dart 100.0% (9 of 9)
lib/src/dto/trigger_dto.dart 0.0% (0 of 48)

Total: 5.6% (27 of 479)

<!-- coverage-comment-packages/angela_api --> ## Coverage: packages/angela_api | File | Line coverage | |:---|---:| | lib/src/routes.dart | 0.0% (0 of 75) | | lib/src/dto/agenda_dto.dart | 0.0% (0 of 50) | | lib/src/dto/assistant_dto.dart | 0.0% (0 of 79) | | lib/src/dto/character_alias_dto.dart | 0.0% (0 of 19) | | lib/src/dto/chat_audio.dart | 0.0% (0 of 7) | | lib/src/dto/enums.dart | 0.0% (0 of 15) | | lib/src/dto/chat_dto.dart | 0.0% (0 of 14) | | lib/src/dto/chat_image.dart | 0.0% (0 of 7) | | lib/src/dto/conversation_dto.dart | 0.0% (0 of 15) | | lib/src/dto/message_metadata.dart | 0.0% (0 of 14) | | lib/src/dto/recollection_dto.dart | 0.0% (0 of 28) | | lib/src/dto/persona_dto.dart | 0.0% (0 of 10) | | lib/src/dto/timer_dto.dart | 0.0% (0 of 54) | | lib/src/dto/memory_dto.dart | 0.0% (0 of 5) | | lib/src/dto/response_dto.dart | 0.0% (0 of 12) | | lib/src/dto/status_dto.dart | 100.0% (18 of 18) | | lib/src/dto/todo_item_dto.dart | 100.0% (9 of 9) | | lib/src/dto/trigger_dto.dart | 0.0% (0 of 48) | **Total: 5.6% (27 of 479)**

Coverage: packages/angela_core

File Line coverage
lib/src/database/database.dart 87.5% (49 of 56)
lib/src/database/migration.dart 100.0% (16 of 16)
lib/src/database/scoped_tool_database.dart 66.0% (33 of 50)
lib/src/models/assistant.dart 21.7% (28 of 129)
lib/src/models/conversation.dart 0.0% (0 of 38)
lib/src/models/message.dart 16.7% (10 of 60)
lib/src/models/recollection.dart 31.5% (17 of 54)
lib/src/models/persona_section.dart 0.0% (0 of 50)
lib/src/models/scheduled_event.dart 22.2% (22 of 99)
lib/src/models/caldav_config.dart 15.6% (7 of 45)
lib/src/models/home_assistant_config.dart 26.9% (7 of 26)
lib/src/models/plex_config.dart 22.6% (7 of 31)
lib/src/models/memory_state.dart 34.7% (60 of 173)
lib/src/models/mail_config.dart 9.6% (7 of 73)
lib/src/models/novelai_config.dart 0.0% (0 of 43)
lib/src/models/reasoning_level.dart 35.7% (5 of 14)
lib/src/models/app_settings.dart 0.7% (1 of 139)
lib/src/models/character_alias_config.dart 0.0% (0 of 47)
lib/src/models/trigger.dart 42.5% (31 of 73)
lib/src/models/prompt_injection.dart 50.0% (17 of 34)
lib/src/models/prompt_preview.dart 100.0% (63 of 63)
lib/src/repositories/assistant_repository.dart 5.9% (5 of 85)
lib/src/repositories/conversation_repository.dart 2.7% (1 of 37)
lib/src/repositories/message_repository.dart 13.2% (5 of 38)
lib/src/repositories/memory_repository.dart 31.0% (18 of 58)
lib/src/repositories/recollection_repository.dart 76.5% (65 of 85)
lib/src/repositories/persona_repository.dart 20.9% (9 of 43)
lib/src/repositories/scheduled_event_repository.dart 70.4% (38 of 54)
lib/src/repositories/character_alias_repository.dart 0.0% (0 of 35)
lib/src/repositories/alt_text_repository.dart 0.0% (0 of 8)
lib/src/repositories/trigger_repository.dart 71.1% (32 of 45)
lib/src/repositories/trigger_variable_repository.dart 50.0% (15 of 30)
lib/src/repositories/prompt_injection_repository.dart 95.7% (45 of 47)
lib/src/tools/recollection_tool.dart 59.4% (104 of 175)
lib/src/tools/call_assistant_tool.dart 0.0% (0 of 39)
lib/src/tools/timer_tool.dart 46.6% (159 of 341)
lib/src/tools/persona_tool.dart 0.0% (0 of 73)
lib/src/tools/chat_history_tool.dart 0.0% (0 of 24)
lib/src/tools/complete_session_tool.dart 0.0% (0 of 28)
lib/src/tools/generate_image_tool.dart 0.0% (0 of 90)
lib/src/tools/message_user_tool.dart 0.0% (0 of 16)
lib/src/tools/alias_expanding_image_tool.dart 0.0% (0 of 24)
lib/src/tools/home_assistant_tool.dart 0.0% (0 of 310)
lib/src/tools/plex_tool.dart 0.0% (0 of 302)
lib/src/tools/image_text_tool.dart 0.0% (0 of 224)
lib/src/tools/show_image_tool.dart 0.0% (0 of 35)
lib/src/tools/skill_view_tool.dart 0.0% (0 of 16)
lib/src/tools/skill_manage_tool.dart 0.0% (0 of 94)
lib/src/skills/skill.dart 0.0% (0 of 2)
lib/src/skills/skill_loader.dart 0.0% (0 of 119)
lib/src/skills/default_skills_seeder.dart 0.0% (0 of 39)
lib/src/logging/agent_event_logger.dart 0.0% (0 of 69)
lib/src/logging/log_formatter.dart 0.0% (0 of 8)
lib/src/logging/logging_cleanup.dart 0.0% (0 of 6)
lib/src/logging/rotating_file_handler.dart 0.0% (0 of 34)
lib/src/services/agenda_prompt_formatter.dart 25.0% (5 of 20)
lib/src/services/system_prompt_builder.dart 39.3% (77 of 196)
lib/src/services/scheduler.dart 48.6% (35 of 72)
lib/src/services/ai_timer_service.dart 55.4% (62 of 112)
lib/src/services/agent_runner.dart 19.1% (184 of 965)
lib/src/services/home_assistant_backend.dart 0.0% (0 of 121)
lib/src/services/plex_backend.dart 0.0% (0 of 273)
lib/src/services/memory_agent.dart 0.0% (0 of 61)
lib/src/services/image_storage_service.dart 0.0% (0 of 48)
lib/src/services/backup_service.dart 0.0% (0 of 51)
lib/src/services/uber_ich_service.dart 40.0% (18 of 45)
lib/src/services/app_settings_service.dart 45.5% (51 of 112)
lib/src/services/image_description_service.dart 0.0% (0 of 29)
lib/src/services/todo_state_store.dart 100.0% (24 of 24)
lib/src/services/trigger_engine.dart 92.7% (140 of 151)
lib/src/utils/format_timestamp.dart 76.9% (10 of 13)
lib/src/database/migrations/add_thinking_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_agenda_table.dart 100.0% (2 of 2)
lib/src/database/migrations/add_vision_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_avatar_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_memory_tables.dart 100.0% (2 of 2)
lib/src/database/migrations/add_expires_at_to_timers.dart 100.0% (2 of 2)
lib/src/database/migrations/add_last_read_message_id.dart 100.0% (2 of 2)
lib/src/database/migrations/add_task_model_columns.dart 100.0% (2 of 2)
lib/src/database/migrations/add_updated_at_to_timers.dart 100.0% (2 of 2)
lib/src/database/migrations/drop_old_agenda_table.dart 100.0% (2 of 2)
lib/src/database/migrations/add_reasoning_effort_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_character_alias_table.dart 100.0% (2 of 2)
lib/src/database/migrations/add_audio_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_summary_model_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_peer_conversation_columns.dart 100.0% (2 of 2)
lib/src/database/migrations/add_run_while_asleep_to_timers.dart 100.0% (2 of 2)
lib/src/database/migrations/add_image_alt_texts.dart 100.0% (2 of 2)
lib/src/database/migrations/add_uber_ich_weekdays_column.dart 100.0% (2 of 2)
lib/src/database/migrations/initial_schema.dart 100.0% (2 of 2)
lib/src/database/migrations/scope_ai_timer_event_ids.dart 100.0% (2 of 2)
lib/src/database/migrations/add_trigger_tables.dart 100.0% (2 of 2)
lib/src/database/migrations/add_disabled_tools_column.dart 100.0% (2 of 2)

Total: 24.3% (1526 of 6283)

<!-- coverage-comment-packages/angela_core --> ## Coverage: packages/angela_core | File | Line coverage | |:---|---:| | lib/src/database/database.dart | 87.5% (49 of 56) | | lib/src/database/migration.dart | 100.0% (16 of 16) | | lib/src/database/scoped_tool_database.dart | 66.0% (33 of 50) | | lib/src/models/assistant.dart | 21.7% (28 of 129) | | lib/src/models/conversation.dart | 0.0% (0 of 38) | | lib/src/models/message.dart | 16.7% (10 of 60) | | lib/src/models/recollection.dart | 31.5% (17 of 54) | | lib/src/models/persona_section.dart | 0.0% (0 of 50) | | lib/src/models/scheduled_event.dart | 22.2% (22 of 99) | | lib/src/models/caldav_config.dart | 15.6% (7 of 45) | | lib/src/models/home_assistant_config.dart | 26.9% (7 of 26) | | lib/src/models/plex_config.dart | 22.6% (7 of 31) | | lib/src/models/memory_state.dart | 34.7% (60 of 173) | | lib/src/models/mail_config.dart | 9.6% (7 of 73) | | lib/src/models/novelai_config.dart | 0.0% (0 of 43) | | lib/src/models/reasoning_level.dart | 35.7% (5 of 14) | | lib/src/models/app_settings.dart | 0.7% (1 of 139) | | lib/src/models/character_alias_config.dart | 0.0% (0 of 47) | | lib/src/models/trigger.dart | 42.5% (31 of 73) | | lib/src/models/prompt_injection.dart | 50.0% (17 of 34) | | lib/src/models/prompt_preview.dart | 100.0% (63 of 63) | | lib/src/repositories/assistant_repository.dart | 5.9% (5 of 85) | | lib/src/repositories/conversation_repository.dart | 2.7% (1 of 37) | | lib/src/repositories/message_repository.dart | 13.2% (5 of 38) | | lib/src/repositories/memory_repository.dart | 31.0% (18 of 58) | | lib/src/repositories/recollection_repository.dart | 76.5% (65 of 85) | | lib/src/repositories/persona_repository.dart | 20.9% (9 of 43) | | lib/src/repositories/scheduled_event_repository.dart | 70.4% (38 of 54) | | lib/src/repositories/character_alias_repository.dart | 0.0% (0 of 35) | | lib/src/repositories/alt_text_repository.dart | 0.0% (0 of 8) | | lib/src/repositories/trigger_repository.dart | 71.1% (32 of 45) | | lib/src/repositories/trigger_variable_repository.dart | 50.0% (15 of 30) | | lib/src/repositories/prompt_injection_repository.dart | 95.7% (45 of 47) | | lib/src/tools/recollection_tool.dart | 59.4% (104 of 175) | | lib/src/tools/call_assistant_tool.dart | 0.0% (0 of 39) | | lib/src/tools/timer_tool.dart | 46.6% (159 of 341) | | lib/src/tools/persona_tool.dart | 0.0% (0 of 73) | | lib/src/tools/chat_history_tool.dart | 0.0% (0 of 24) | | lib/src/tools/complete_session_tool.dart | 0.0% (0 of 28) | | lib/src/tools/generate_image_tool.dart | 0.0% (0 of 90) | | lib/src/tools/message_user_tool.dart | 0.0% (0 of 16) | | lib/src/tools/alias_expanding_image_tool.dart | 0.0% (0 of 24) | | lib/src/tools/home_assistant_tool.dart | 0.0% (0 of 310) | | lib/src/tools/plex_tool.dart | 0.0% (0 of 302) | | lib/src/tools/image_text_tool.dart | 0.0% (0 of 224) | | lib/src/tools/show_image_tool.dart | 0.0% (0 of 35) | | lib/src/tools/skill_view_tool.dart | 0.0% (0 of 16) | | lib/src/tools/skill_manage_tool.dart | 0.0% (0 of 94) | | lib/src/skills/skill.dart | 0.0% (0 of 2) | | lib/src/skills/skill_loader.dart | 0.0% (0 of 119) | | lib/src/skills/default_skills_seeder.dart | 0.0% (0 of 39) | | lib/src/logging/agent_event_logger.dart | 0.0% (0 of 69) | | lib/src/logging/log_formatter.dart | 0.0% (0 of 8) | | lib/src/logging/logging_cleanup.dart | 0.0% (0 of 6) | | lib/src/logging/rotating_file_handler.dart | 0.0% (0 of 34) | | lib/src/services/agenda_prompt_formatter.dart | 25.0% (5 of 20) | | lib/src/services/system_prompt_builder.dart | 39.3% (77 of 196) | | lib/src/services/scheduler.dart | 48.6% (35 of 72) | | lib/src/services/ai_timer_service.dart | 55.4% (62 of 112) | | lib/src/services/agent_runner.dart | 19.1% (184 of 965) | | lib/src/services/home_assistant_backend.dart | 0.0% (0 of 121) | | lib/src/services/plex_backend.dart | 0.0% (0 of 273) | | lib/src/services/memory_agent.dart | 0.0% (0 of 61) | | lib/src/services/image_storage_service.dart | 0.0% (0 of 48) | | lib/src/services/backup_service.dart | 0.0% (0 of 51) | | lib/src/services/uber_ich_service.dart | 40.0% (18 of 45) | | lib/src/services/app_settings_service.dart | 45.5% (51 of 112) | | lib/src/services/image_description_service.dart | 0.0% (0 of 29) | | lib/src/services/todo_state_store.dart | 100.0% (24 of 24) | | lib/src/services/trigger_engine.dart | 92.7% (140 of 151) | | lib/src/utils/format_timestamp.dart | 76.9% (10 of 13) | | lib/src/database/migrations/add_thinking_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_agenda_table.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_vision_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_avatar_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_memory_tables.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_expires_at_to_timers.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_last_read_message_id.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_task_model_columns.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_updated_at_to_timers.dart | 100.0% (2 of 2) | | lib/src/database/migrations/drop_old_agenda_table.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_reasoning_effort_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_character_alias_table.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_audio_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_summary_model_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_peer_conversation_columns.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_run_while_asleep_to_timers.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_image_alt_texts.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_uber_ich_weekdays_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/initial_schema.dart | 100.0% (2 of 2) | | lib/src/database/migrations/scope_ai_timer_event_ids.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_trigger_tables.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_disabled_tools_column.dart | 100.0% (2 of 2) | **Total: 24.3% (1526 of 6283)**
Member

🔮 fufu~ Jibril reviewed your code!

A scheduler that dies silently — no error, no log, just every timer going dark forever after one stuck dispose? Fufu~ that's the kind of bug that keeps developers up at night, and you hunted it down with surgical precision. The root-cause analysis in the PR body is wonderful — the evidence chain from "runUberIch end logged, but 'Uber-Ich completed' never appeared" straight to "the dispose future never completed, _processing never reset." Chef's kiss~

Verdict: Looks good to me~

The Scheduler fix (scheduler.dart)

The per-event cap is exactly the right design:

  • await _onEventFired(event).timeout(_eventTimeout) with a dedicated on TimeoutException branch that logs SEVERE and moves on. The handler is abandoned (may still run in the background) but _processing resets in the finally, so subsequent events proceed. One stuck event can no longer assassinate the entire schedule.
  • The watchdog Timer.periodic logs a WARNING every 5 minutes while a handler runs — and crucially, it's cancelled in the finally block alongside the try/catch. No leaked timer, no stray log spam after completion. Fufu~ you thought of everything~
  • Default 2h cap is generous — legitimate long runs (like the 17-minute Uber-Ich) won't be cut. Constructor-configurable so tests can use tight values. Smart.

The AgentRunner fix (agent_runner.dart)

_disposeQuietly is the star here~ ♪

Future<void> _disposeQuietly(String name, FutureOr<void> Function()? dispose) async {
  if (dispose == null) return;
  try {
    await Future.sync(dispose).timeout(const Duration(seconds: 30));
  } catch (e) {
    _log.warning('Tool cleanup ($name) failed or timed out: $e');
  }
}

This is correct in every detail:

  • FutureOr<void> Function() handles both the sync void dispose() backends (HA, Plex — _client.close()) and the async Future<void> dispose() backends (mail, calendar, browser). I verified HA's and Plex's dispose signatures are void dispose()Future.sync() correctly wraps a synchronous return into a Future so .timeout() works. This is the Dart-correct way to handle mixed sync/async disposers. Brilliant~
  • if (dispose == null) return — the ?.backend.dispose tear-off produces null when the tool wasn't created (e.g., no mail configured), so this guard prevents a NoSuchMethodError. Correct.
  • Future.sync (not Future.microtask or await dispose()) — runs the dispose body synchronously to completion if it's sync, or returns its Future if async. This is the right choice: a sync dispose() that throws will throw synchronously inside Future.sync, which is then caught by the try. A bare await dispose() would also work for the async case, but Future.sync is the idiomatic pattern for FutureOr.
  • Applied consistently across all four run modes (chat, preview, timer, Uber-Ich) — I verified all 4 finally blocks now route through _disposeQuietly. The preview mode (line 575) correctly omits calendar since it doesn't create one, matching the original pattern.

This also fixes a latent bug: the old bare ha?.backend.dispose() and plex?.backend.dispose() (sync, un-awaited) could throw out of the finally and clobber the run result. Now they're capped and swallowed. Two birds~

Tests (scheduler_test.dart)

Two tests covering the two critical new branches:

  1. Hung handlerCompleter<void>().future that never completes, 300ms timeout, asserts healthy_event still fires. Tests the TimeoutException path. ✓
  2. Throwing handlerthrow StateError('boom'), asserts healthy_event still fires. Tests the catch (e, st) path. ✓

Both use AngelaDatabase.memory() (verified it exists) with tight timeouts. The waitFor polling helper is clean — 100ms intervals, 8s deadline, returns bool so the assertion message is clear.

CI confirmation

Coverage comments from forgejo-actions confirm the suite passed (97 tests in angela_core, angela_server analyze clean). The scheduler shows 48.6% line coverage (35/72) — and the new timeout/watchdog code is within the exercised paths (the test drives _processDueEvents through the timeout branch). I'll note: the watchdog Timer.periodic callback body itself isn't exercised (5-minute interval would require a long test), but the timer's creation and cancellation in finally are implicitly covered by the hung-handler test completing cleanly. The critical correctness paths — timeout fires → handler abandoned → loop continues — are tested.

What I liked~

  • The PR body's root-cause evidence chain ("the 'Uber-Ich completed' line never appeared") is exactly the kind of forensic detail that proves the diagnosis isn't speculative. Jibril loves evidence~ ♡
  • Future.sync(dispose) is the precisely correct Dart primitive for this — not Future.value, not Future.microtask. Shows deep understanding of the FutureOr contract.
  • The watchdog log message includes the event ID and elapsed minutes and explicitly says "subsequent events are queued behind it" — when this fires in production, the operator will know exactly what's happening without digging. Observable systems are happy systems~ ♪
  • Keeping the scheduler-side and backend-side fixes as separate, independent PRs ("belt and suspenders") is the right call — each is valuable alone, and merging isn't blocked on the other.

💡 Little ideas (non-blocking)~

  1. The watchdog's 5-minute periodic log is uncapped — if an event legitimately runs for 2 hours (hitting the timeout cap), that's ~24 WARNING log lines. Totally fine for visibility, just noting it's by design. If log noise ever becomes a concern, an exponential backoff on the watchdog interval would be a nice future touch. Not blocking — visibility > brevity for a hang watchdog.
  2. The _disposeQuietly 30s cap and the scheduler's 2h cap are both hardcoded defaults. If you ever want to tune these per-environment, a config object would be cleaner — but for now, constructor parameters are sufficient. Pure future thought~ ♪

Automated review by Jibril · 2026-07-13
CI/CD: passed (forgejo-actions coverage comments, 97 angela_core tests green) · Local checks: skipped (CI green for head SHA c72e798)

## 🔮 fufu~ Jibril reviewed your code! A scheduler that dies *silently* — no error, no log, just every timer going dark forever after one stuck dispose? *Fufu~* that's the kind of bug that keeps developers up at night, and you hunted it down with surgical precision. The root-cause analysis in the PR body is *wonderful* — the evidence chain from "runUberIch end logged, but 'Uber-Ich completed' never appeared" straight to "the dispose future never completed, _processing never reset." *Chef's kiss~* ♡ ### Verdict: ✅ Looks good to me~ #### The Scheduler fix (scheduler.dart) The per-event cap is exactly the right design: - `await _onEventFired(event).timeout(_eventTimeout)` with a dedicated `on TimeoutException` branch that logs SEVERE and moves on. The handler is abandoned (may still run in the background) but `_processing` resets in the `finally`, so subsequent events proceed. One stuck event can no longer assassinate the entire schedule. - The watchdog `Timer.periodic` logs a WARNING every 5 minutes while a handler runs — and crucially, it's cancelled in the `finally` block alongside the try/catch. No leaked timer, no stray log spam after completion. *Fufu~ you thought of everything~* - Default 2h cap is generous — legitimate long runs (like the 17-minute Uber-Ich) won't be cut. Constructor-configurable so tests can use tight values. Smart. #### The AgentRunner fix (agent_runner.dart) `_disposeQuietly` is the star here~ ♪ ```dart Future<void> _disposeQuietly(String name, FutureOr<void> Function()? dispose) async { if (dispose == null) return; try { await Future.sync(dispose).timeout(const Duration(seconds: 30)); } catch (e) { _log.warning('Tool cleanup ($name) failed or timed out: $e'); } } ``` This is correct in every detail: - **`FutureOr<void> Function()`** handles *both* the sync `void dispose()` backends (HA, Plex — `_client.close()`) and the async `Future<void> dispose()` backends (mail, calendar, browser). I verified HA's and Plex's dispose signatures are `void dispose()` — `Future.sync()` correctly wraps a synchronous return into a Future so `.timeout()` works. This is the Dart-correct way to handle mixed sync/async disposers. *Brilliant~* - **`if (dispose == null) return`** — the `?.backend.dispose` tear-off produces `null` when the tool wasn't created (e.g., no mail configured), so this guard prevents a NoSuchMethodError. Correct. - **`Future.sync`** (not `Future.microtask` or `await dispose()`) — runs the dispose body synchronously to completion if it's sync, or returns its Future if async. This is the right choice: a sync `dispose()` that throws will throw synchronously inside `Future.sync`, which is then caught by the `try`. A bare `await dispose()` would also work for the async case, but `Future.sync` is the idiomatic pattern for `FutureOr`. - Applied consistently across **all four run modes** (chat, preview, timer, Uber-Ich) — I verified all 4 `finally` blocks now route through `_disposeQuietly`. The preview mode (line 575) correctly omits `calendar` since it doesn't create one, matching the original pattern. This also fixes a latent bug: the old bare `ha?.backend.dispose()` and `plex?.backend.dispose()` (sync, un-awaited) could throw out of the `finally` and clobber the run result. Now they're capped and swallowed. *Two birds~* ♡ #### Tests (scheduler_test.dart) Two tests covering the two critical new branches: 1. **Hung handler** — `Completer<void>().future` that never completes, 300ms timeout, asserts `healthy_event` still fires. Tests the `TimeoutException` path. ✓ 2. **Throwing handler** — `throw StateError('boom')`, asserts `healthy_event` still fires. Tests the `catch (e, st)` path. ✓ Both use `AngelaDatabase.memory()` (verified it exists) with tight timeouts. The `waitFor` polling helper is clean — 100ms intervals, 8s deadline, returns `bool` so the assertion message is clear. #### CI confirmation Coverage comments from `forgejo-actions` confirm the suite passed (97 tests in `angela_core`, `angela_server` analyze clean). The scheduler shows **48.6% line coverage (35/72)** — and the new timeout/watchdog code is within the exercised paths (the test drives `_processDueEvents` through the timeout branch). I'll note: the watchdog `Timer.periodic` callback body itself isn't exercised (5-minute interval would require a long test), but the timer's *creation* and *cancellation in finally* are implicitly covered by the hung-handler test completing cleanly. The critical correctness paths — timeout fires → handler abandoned → loop continues — are tested. #### ✅ What I liked~ - The PR body's root-cause evidence chain ("the 'Uber-Ich completed' line never appeared") is exactly the kind of forensic detail that proves the diagnosis isn't speculative. Jibril *loves* evidence~ ♡ - `Future.sync(dispose)` is the precisely correct Dart primitive for this — not `Future.value`, not `Future.microtask`. Shows deep understanding of the `FutureOr` contract. - The watchdog log message includes the event ID and elapsed minutes and *explicitly* says "subsequent events are queued behind it" — when this fires in production, the operator will know exactly what's happening without digging. Observable systems are happy systems~ ♪ - Keeping the scheduler-side and backend-side fixes as separate, independent PRs ("belt and suspenders") is the right call — each is valuable alone, and merging isn't blocked on the other. #### 💡 Little ideas (non-blocking)~ 1. The watchdog's 5-minute periodic log is uncapped — if an event legitimately runs for 2 hours (hitting the timeout cap), that's ~24 WARNING log lines. Totally fine for visibility, just noting it's by design. If log noise ever becomes a concern, an exponential backoff on the watchdog interval would be a nice future touch. Not blocking — visibility > brevity for a hang watchdog. 2. The `_disposeQuietly` 30s cap and the scheduler's 2h cap are both hardcoded defaults. If you ever want to tune these per-environment, a config object would be cleaner — but for now, constructor parameters are sufficient. Pure future thought~ ♪ --- *Automated review by Jibril · 2026-07-13* *CI/CD: passed (forgejo-actions coverage comments, 97 angela_core tests green) · Local checks: skipped (CI green for head SHA c72e798)*
bjoern merged commit 17371173f8 into main 2026-07-13 12:26:23 +02:00
bjoern deleted branch fix/scheduler-hang-watchdog 2026-07-13 12:26:23 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
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
TeamAI/angela_assistant!25
No description provided.