* feat(subconscious): stabilize heartbeat + subconscious loop (#392) - Enable heartbeat by default (enabled=true, inference_enabled=true, 5min interval) - Seed system tasks on engine init, not first tick - SQLite-backed task/log/escalation persistence - Overlap guard with generation counter — stale ticks are cancelled - Single log entry per task per tick, updated in place (in_progress → act/noop/escalate/failed/cancelled) - Rate-limit retry (429 only) for agentic-v1 cloud model calls - Approval gate: unsolicited write actions on read-only tasks require user approval - Analysis-only mode for agentic-v1 on read-only escalations - Non-blocking status RPC — reads from DB, never blocks on engine mutex - Frontend: system vs user task distinction, toggle switches, expandable activity log - Frontend: 3s auto-poll on Subconscious tab, skill-related escalation navigation - Consecutive failure counter in status (resets on success) - last_tick_at only advances on successful evaluation - Missing LLM evaluation fallback — unevaluated tasks default to noop - Docs: subconscious.md architecture guide, memory-sync-functions.md reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix Prettier formatting for subconscious frontend files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: retrigger checks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(heartbeat): use disabled config in run_returns_immediately_when_disabled test HeartbeatConfig::default() has enabled: true, so run() entered the infinite loop and never returned — hanging the test (and CI) indefinitely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(subconscious): remove HEARTBEAT.md task import, use SQLite as sole task source Tasks are now managed exclusively in SQLite via the Subconscious UI. HEARTBEAT.md is retained for instructions/context only, not as a task list. Situation report now reads pending tasks from SQLite instead of HEARTBEAT.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt on subconscious engine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 KiB
Subconscious Loop
Background task evaluation and execution system. Periodically checks user-defined and system tasks against the current workspace state, decides what to do, and either acts autonomously or escalates to the user.
Architecture
┌─────────────────────────────────────────────────────────┐
│ Heartbeat Engine │
│ (sleeps N minutes between ticks) │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Subconscious Engine │
│ │
│ 1. Load due tasks from SQLite │
│ 2. Insert in_progress log entries │
│ 3. Build situation report (memory + workspace state) │
│ 4. Evaluate tasks with local Ollama model │
│ 5. Execute decisions (act / noop / escalate) │
│ 6. Update log entries in place │
└─────────────────────────────────────────────────────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
noop act escalate
(skip) (execute) (agentic-v1)
Key files
| File | Purpose |
|---|---|
src/openhuman/heartbeat/engine.rs |
Periodic scheduler, delegates to subconscious engine |
src/openhuman/subconscious/engine.rs |
Core tick logic, state management, overlap guard |
src/openhuman/subconscious/executor.rs |
Task execution routing (local model vs agentic-v1) |
src/openhuman/subconscious/prompt.rs |
Prompt builders for evaluation and execution |
src/openhuman/subconscious/store.rs |
SQLite persistence (tasks, log, escalations) |
src/openhuman/subconscious/types.rs |
Data types and enums |
src/openhuman/subconscious/situation_report.rs |
Builds context from memory and workspace state |
src/openhuman/subconscious/global.rs |
Global singleton shared between heartbeat and RPC |
src/openhuman/subconscious/schemas.rs |
RPC endpoint handlers |
app/src/hooks/useSubconscious.ts |
Frontend hook for data fetching and actions |
app/src/pages/Intelligence.tsx |
UI rendering (Subconscious tab) |
app/src/utils/tauriCommands/subconscious.ts |
TypeScript RPC wrappers |
Task Types
System tasks
Seeded automatically on engine initialization. Cannot be deleted, only disabled.
Default system tasks:
- Check connected skills for errors or disconnections
- Review new memory updates for actionable items
- Monitor system health (Ollama, memory, connections)
Additional system tasks are imported from HEARTBEAT.md in the workspace directory (one task per - line).
User tasks
Added manually via the UI. Can be toggled on/off and deleted.
Examples:
- "Check urgent emails" (read-only)
- "Send daily summary to Slack" (write intent)
- "Summarize Notion updates" (read-only)
Tick Lifecycle
1. Overlap guard
Each tick gets a monotonically increasing generation counter. If a new tick starts while the old one is still running (e.g., slow LLM call), the old tick's results are discarded and its in_progress log entries are marked as cancelled.
The heartbeat uses tokio::time::sleep (not interval) so ticks never stack up.
2. Load due tasks
Query: enabled, not completed, next_run_at <= now or never run.
3. Log as in_progress
Each due task gets a single log row inserted with decision = "in_progress". This row is updated in place as the task progresses — no duplicate rows.
4. Evaluate with local model
The local Ollama model receives all due tasks + a situation report and returns a per-task decision:
| Decision | Meaning |
|---|---|
noop |
Nothing relevant right now |
act |
Something relevant found — execute the task |
escalate |
Needs deeper reasoning — hand off to agentic-v1 |
5. Execute
Routing depends on the decision and the task's intent:
Decision: noop
→ Update log to "noop", advance schedule
Decision: act
├─ Task has write intent (needs_tools = true)
│ → Execute with local model
│
└─ Task is read-only
→ Execute with local model
Decision: escalate
├─ Task has write intent (needs_tools = true)
│ → Run agentic-v1 with full permissions
│ → No approval needed (user explicitly asked for the write action)
│
└─ Task is read-only
→ Run agentic-v1 in analysis-only mode
→ If response contains "RECOMMENDED ACTION:" (unsolicited write)
│ → Create escalation for user approval
│ → On approval → run agentic-v1 with full permissions
└─ Otherwise → log result, done
6. Update log entry
The same row inserted in step 3 is updated to the final state:
| Decision | Dot color | Text |
|---|---|---|
in_progress |
Blue (pulsing) | "Evaluating..." |
act |
Green | Result text |
noop |
Gray | "Nothing new" |
escalate |
Amber | "Waiting for approval" |
failed |
Coral | Error message |
cancelled |
Gray | "Cancelled" |
dismissed |
Gray | "Skipped" |
Execution Models
Local Ollama model
Used for:
- Task evaluation (all tasks, every tick)
- Text-only task execution (summarize, check, monitor, review)
No cost, no rate limits, runs on-device.
agentic-v1 (cloud)
Used for:
- Tool-required task execution (send, post, delete, create)
- Analysis-only mode for read-only tasks escalated by the local model
Rate-limit retry: up to 3 attempts with exponential backoff (2s, 4s, 8s) on 429 errors.
Approval Gate
Approval is only required when the AI wants to take a write action that the user didn't explicitly request.
| Task intent | AI wants to write | Approval needed? |
|---|---|---|
| "Send digest to Slack" (write) | Yes | No — user asked for it |
| "Check urgent emails" (read) | No | No — read-only result |
| "Check urgent emails" (read) | Yes (wants to forward them) | Yes — unsolicited write |
The approval flow:
- agentic-v1 runs in analysis-only mode
- Response contains
RECOMMENDED ACTION: Forward 3 urgent emails to #team-alerts - Escalation card appears in UI under "Approval Needed"
- User clicks "Go ahead" → agentic-v1 runs again with full permissions
- Or user clicks "Skip" → nothing happens
Skill-related escalations
Escalations related to skills (detected by keywords: skill, oauth, notion, gmail, integration, disconnect, re-auth) show a "Fix in Skills" button that navigates to the Skills page instead of "Go ahead".
Failure Handling
Consecutive failure counter
Tracked in EngineState.consecutive_failures. Increments when the entire LLM evaluation fails (Ollama down, network error). Resets to 0 on any successful tick. Surfaced in the UI status bar as "N failed" in coral.
Individual task execution failures do NOT increment this counter — they are logged per-task but the tick itself is considered successful.
last_tick_at advancement
last_tick_at only advances on successful ticks. If the LLM evaluation fails or the tick is cancelled, last_tick_at stays unchanged so the next tick's situation report covers the same time range — nothing is missed.
Configuration
In config.toml under [heartbeat]:
[heartbeat]
enabled = true # Enable the heartbeat loop
interval_minutes = 5 # Tick interval (minimum 5)
inference_enabled = true # Enable local model evaluation
context_budget_tokens = 40000 # Max tokens for situation report
Defaults: enabled = true, interval_minutes = 5, inference_enabled = true.
SQLite Schema
Database: {workspace_dir}/subconscious/subconscious.db
subconscious_tasks
| Column | Type | Description |
|---|---|---|
| id | TEXT PK | UUID |
| title | TEXT | Task description |
| source | TEXT | "system" or "user" |
| recurrence | TEXT | "pending", "once", or "cron:expr" |
| enabled | INTEGER | 1 = active, 0 = paused |
| last_run_at | REAL | Unix timestamp of last evaluation |
| next_run_at | REAL | Unix timestamp of next scheduled run |
| completed | INTEGER | 1 = done (one-off tasks) |
| created_at | REAL | Unix timestamp |
subconscious_log
| Column | Type | Description |
|---|---|---|
| id | TEXT PK | UUID |
| task_id | TEXT | FK to tasks |
| tick_at | REAL | Unix timestamp of the tick |
| decision | TEXT | in_progress, act, noop, escalate, failed, cancelled, dismissed |
| result | TEXT | Result text or error message |
| duration_ms | INTEGER | Execution duration |
| created_at | REAL | Unix timestamp |
subconscious_escalations
| Column | Type | Description |
|---|---|---|
| id | TEXT PK | UUID |
| task_id | TEXT | FK to tasks |
| log_id | TEXT | FK to log entry |
| title | TEXT | Escalation title |
| description | TEXT | What needs approval |
| priority | TEXT | critical, important, normal |
| status | TEXT | pending, approved, dismissed |
| created_at | REAL | Unix timestamp |
| resolved_at | REAL | When approved/dismissed |
RPC Endpoints
All under openhuman.subconscious_*:
| Method | Description |
|---|---|
subconscious_status |
Get engine status (enabled, ticks, failures) |
subconscious_trigger |
Manually trigger a tick (runs in background, returns immediately) |
subconscious_tasks_list |
List all tasks |
subconscious_tasks_add |
Add a user task |
subconscious_tasks_update |
Update task (title, enabled, recurrence) |
subconscious_tasks_remove |
Remove a user task (system tasks can only be disabled) |
subconscious_log_list |
List activity log entries |
subconscious_escalations_list |
List escalations (filterable by status) |
subconscious_escalations_approve |
Approve and execute an escalation |
subconscious_escalations_dismiss |
Dismiss an escalation |
UI (Intelligence Page → Subconscious Tab)
Status bar
Shows: task count, total ticks, last tick time, consecutive failures (if > 0).
Active Tasks
- System tasks: displayed as plain text with green dot and "default" badge. No controls.
- User tasks: toggle switch (enable/disable) + delete button on hover.
- Add task: text input + "Add" button at the bottom.
Approval Needed
Amber cards for pending escalations. Each shows title, description, priority badge.
- "Go ahead": approve and execute the write action.
- "Fix in Skills": shown for skill-related escalations, navigates to Skills page.
- "Skip": dismiss without executing.
Activity Log
Chronological list of task evaluations. Each entry shows timestamp, colored dot, and result text. Auto-polls every 2s while any entries are in_progress.
Run Now
Triggers a manual tick. The tick runs in the background — the RPC returns immediately and the UI polls for updates.