# PLAN — Agent Tasks, ToolRuntime, and Resource Locking

## Purpose & context

Furoshiki currently runs multi-step tool calling **inside a single user turn** via `llm.call_llm_chat_with_tool_loop` (Telegram listener) and `skill_orchestrator.plan_and_invoke` (reflection paths). This works for short tool sequences but cannot support long-horizon agentic work like:

> "Check my email and get rid of any junk mail."

That kind of task needs to: span many model rounds, persist across user turns, survive interruptions, gate irreversible actions behind explicit approval, and run **concurrently** with other long-horizon tasks without two agents stepping on the same resource (e.g. two tasks both deleting from gmail).

This plan introduces three coordinated changes:

1. **`ToolRuntime`** — a single pure-Python policy seam that both existing tool-calling loops delegate to. Owns iteration budgets, dedup, urgency routing, and structured logging. No new LLM calls.
2. **Agent Tasks** — a persistent, resumable task wrapper around the existing tool loop. New SQLite tables, a scheduler tick, and a `request_approval` meta-tool. Runs alongside the listener loop, not replacing it.
3. **Resource Registry + Locks** — a hybrid vocabulary (core canonical names + `plugin:<plugin_id>:*` namespacing) validated at plugin load, with per-task multi-resource locks acquired atomically via `BEGIN IMMEDIATE`, held for the task lifetime, fast-pathed for empty sets.

The result: Furoshiki can run multiple long-horizon agent tasks concurrently, each calling tools across many rounds, with deterministic safety on irreversible operations and no chance of resource collisions between concurrent tasks.

---

## Hard constraints (do not violate)

- **No new data stores.** Everything lives in existing `~/furoshiki/memory/longmemory.sqlite` plus one new JSON config under `config/`.
- **Harness-agnostic.** No Telegram-specific types in core. `source` and `origin_ref` are opaque strings.
- **Generic identifiers only.** No personal names in code, schemas, or log fields.
- **Deterministic safety paths.** Locking, dedup, approval routing, and resource validation are pure Python — never "let the LLM figure it out."
- **Single source of truth per concept.** Registry = vocabulary; lock table = runtime state; plugin manifest = declarations; task steps = canonical task history.
- **Cost discipline.** New code adds **zero** mandatory LLM calls. The only optional new LLM use is approval-routing micro-classification, which fires only when multiple approvals are pending for one source simultaneously.

---

## Architecture overview

```
User turn arrives
  ↓
Listener (telegram_listener.py)
  ↓
Micro-brain routes:
  ├─ short task → call_llm_chat_with_tool_loop (existing path, now via ToolRuntime)
  └─ long-horizon task → agent_tasks.create() → reply "on it" → return

Scheduler tick (new, runs in soul_engine cadence)
  ↓
agent_runner.tick()
  ├─ pull queued/running tasks ordered by created_at
  ├─ for each: try_acquire_locks(task.resource_locks)
  │    ├─ already held by another task → skip, leave queued
  │    └─ acquired → mark running, call run_one_tick(task)
  └─ respect AGENT_RUNNER_MAX_CONCURRENT cap

agent_runner.run_one_tick(task)
  ↓
  rehydrate messages from agent_task_steps
  call_llm_chat_with_tool_loop(messages, tools, runtime, max_rounds=N)
    ├─ each tool round → ToolRuntime.should_call → run_callable_skill → record_call
    ├─ if model calls request_approval meta-tool → status=awaiting_approval, mind_queue, return
    └─ on round budget exhausted → persist, status=running, return
  persist new steps + scratchpad
  if final text → status=done, mind_queue agent_complete

Approval reply from user
  ↓
Listener detects pending approval for source → resolve_approval(task_id, decision)
  ↓
agent_runner picks task back up next tick with synthetic tool_result("approved"|"rejected")
```

---

## Phase 1 — ToolRuntime (foundation, prerequisite for Phase 2)

### 1.1 New module: `scripts/tool_runtime.py`

Pure Python. No LLM calls. Owns policy decisions for **all** tool-calling loops.

**Class: `ToolRuntime`**

Constructor takes a `source` string (`"listener"`, `"agent_runner"`, `"skill_orchestrator"`, future harnesses) and a config dict loaded from `config/tool_runtime.json`.

**Methods:**

- `filter_manifest(global_manifest: list[dict]) -> list[dict]` — return tools visible to this source. Visibility rules in config; default is "all tools visible to all sources."
- `should_call(name: str, args: dict, recent_calls: list[tuple]) -> tuple[bool, str | None]` — returns `(allowed, reason_if_blocked)`. Checks per-tool cooldown and dedup window. `recent_calls` is a list of `(name, args_hash, ts)` tuples from the current task/turn.
- `record_call(name: str, args: dict, result: dict, latency_ms: int) -> None` — writes one row to `system_events` with `kind='tool_call'`, payload includes name, args_hash, urgency, latency, source, task_id (if applicable).
- `route_result(result: dict, task_id: int | None) -> dict | None` — if `result.urgency` in `("high", "urgent")`, returns a `mind_queue` row dict ready to insert. Otherwise None.
- `iteration_budget() -> int` — returns max rounds per tick from config.
- `args_hash(args: dict) -> str` — stable SHA1 of canonical JSON for dedup.

**No threading, no locks, no I/O beyond `system_events` and reading config.** This module is meant to be trivially testable in isolation.

### 1.2 New config: `config/tool_runtime.json`

```json
{
  "sources": {
    "listener": {
      "iteration_budget": 6,
      "dedup_window_seconds": 30,
      "tool_cooldowns": {}
    },
    "agent_runner": {
      "iteration_budget": 8,
      "dedup_window_seconds": 60,
      "tool_cooldowns": {}
    },
    "skill_orchestrator": {
      "iteration_budget": 5,
      "dedup_window_seconds": 30,
      "tool_cooldowns": {}
    }
  },
  "visibility": {
    "default": "all"
  }
}
```

### 1.3 Wire into `scripts/llm.py`

Modify `call_llm_chat_with_tool_loop` to accept an optional `runtime: ToolRuntime | None` parameter. If `None`, behavior is identical to today (back-compat). If provided:

- Cap loop iterations at `runtime.iteration_budget()`.
- Before each `run_callable_skill`, call `runtime.should_call(...)`. If blocked, append a synthetic `role: tool` message with `{"error": "blocked", "reason": ...}` and continue.
- After each `run_callable_skill`, call `runtime.record_call(...)`.
- After each call, call `runtime.route_result(...)` and enqueue any returned mind_queue row.

The listener (`telegram_listener.py`) constructs `ToolRuntime(source="listener", ...)` once per reply and passes it in.

### 1.4 Wire into `scripts/skill_orchestrator.py`

Same pattern in `plan_and_invoke`. Construct `ToolRuntime(source="skill_orchestrator", ...)`.

### 1.5 Tests

- `tests/test_tool_runtime.py` covering: dedup hits, cooldown enforcement, urgency routing, args_hash stability, empty/full visibility filtering.
- Update existing listener loop tests to pass a runtime and assert iteration cap is respected.

---

## Phase 2 — Resource Registry & Validation

### 2.1 New config: `config/resource_registry.json`

Ship with this exact content (no speculative entries):

```json
{
  "core": {
    "gmail": {
      "description": "Google Mail account",
      "lock_on": ["write"],
      "irreversible_ops": ["delete", "send", "trash"]
    },
    "gcal": {
      "description": "Google Calendar",
      "lock_on": ["write"],
      "irreversible_ops": ["delete", "create", "update"]
    },
    "filesystem": {
      "description": "Local files under instance dir",
      "lock_on": ["write"],
      "irreversible_ops": ["write", "delete"]
    },
    "shell": {
      "description": "Subprocess execution",
      "lock_on": ["read", "write"],
      "irreversible_ops": ["*"]
    },
    "web_fetch": {
      "description": "Outbound HTTP",
      "lock_on": [],
      "irreversible_ops": []
    },
    "soul_state": {
      "description": "Emotion + needs vector",
      "lock_on": ["write"],
      "irreversible_ops": ["write"]
    },
    "mind_queue": {
      "description": "Proactive outbound queue",
      "lock_on": ["write"],
      "irreversible_ops": []
    },
    "agent_tasks": {
      "description": "Agent task table",
      "lock_on": ["write"],
      "irreversible_ops": ["cancel"]
    },
    "telegram_outbound": {
      "description": "Telegram send channel",
      "lock_on": ["write"],
      "irreversible_ops": ["send"]
    }
  },
  "plugin": {}
}
```

Key field semantics:
- **`lock_on`**: list of modes that actually acquire a lock. `["write"]` means reads are unlocked. `[]` means the resource never locks (e.g. `web_fetch`). `["read", "write"]` means everything locks.
- **`irreversible_ops`**: feeds into the approval gate (Phase 3); listed for forward compatibility but `requires_approval` on the callable is the authoritative trigger in Phase 1 of agent tasks.

### 2.2 New module: `scripts/resource_registry.py`

**Functions:**

- `load_registry() -> dict` — read JSON, cache.
- `validate_resource(name: str, plugin_id: str) -> tuple[bool, str | None]` — returns `(ok, error_message)`. Rules:
  - If `name` in `core` → ok.
  - If `name` starts with `f"plugin:{plugin_id}:"` → ok, auto-register in `plugin` section if new (write back to JSON).
  - If `name` starts with `"plugin:"` but with a different plugin_id → reject ("cannot claim another plugin's namespace").
  - Otherwise → reject with helpful error listing core resources and showing the namespacing template.
- `locks_on_mode(name: str, mode: str) -> bool` — registry lookup. Returns whether a `(resource, mode)` combination acquires a lock.
- `register_plugin_resource(name: str, plugin_id: str, description: str = "") -> None` — atomic write to registry JSON.

### 2.3 Wire into `scripts/plugin_loader.py`

When loading a plugin:

1. For each `type: callable` hook, read its `resources` field (see Phase 2.4 for format).
2. For each entry, call `validate_resource(name, plugin.id)`.
3. On any validation failure, **reject the entire plugin** with a clear error message naming the offending resource and the helpful suggestion. Do not partially load.
4. On success, store the normalized resource list on the callable manifest entry for later use by ToolRuntime and agent_runner.

### 2.4 Plugin manifest format extension

Update `docs/PLUGIN-SPEC.md` and the loader to accept this on each `type: callable` hook:

```yaml
- type: callable
  name: gmail_trash
  entry: scripts/gmail_trash.py
  description: "Move messages to trash"
  when_to_use: "When the user wants to delete or trash gmail messages"
  requires_approval: true
  resources:
    - {name: gmail, mode: write}
  args_schema:
    type: object
    properties:
      message_ids:
        type: array
        items: {type: string}
```

Format rules:
- `resources` is optional. Missing or empty → no locks, no approval implications from resources.
- Each entry is either a bare string (defaults to `mode: write`) or `{name, mode}` where mode is `"read"` or `"write"`.
- `requires_approval` is a per-callable boolean (default false). This is the **authoritative** approval trigger for Phase 1.

### 2.5 Tests

- `tests/test_resource_registry.py` covering: core acceptance, namespaced acceptance + auto-register, cross-namespace rejection, bare-string rejection with helpful error, lock_on mode lookups.
- `tests/test_plugin_loader_resources.py` covering: valid plugin loads with normalized resources; invalid plugin is fully rejected (not partially loaded).

---

## Phase 3 — Agent Tasks (the main event)

### 3.1 SQLite schema migration

Add to the schema initialization in `scripts/db.py` (or wherever schema lives):

```sql
CREATE TABLE IF NOT EXISTS agent_tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    status TEXT NOT NULL,                 -- queued|running|awaiting_approval|done|failed|cancelled
    source TEXT NOT NULL,                 -- 'listener', future harness ids
    origin_ref TEXT NOT NULL,             -- opaque per-source id (e.g. chat_id as string)
    handle TEXT NOT NULL,                 -- short per-source id like 'a4'
    goal_text TEXT NOT NULL,
    scratchpad_json TEXT NOT NULL DEFAULT '{}',
    resource_locks_json TEXT NOT NULL DEFAULT '[]',  -- canonical resource names this task holds
    iteration_count INTEGER NOT NULL DEFAULT 0,
    iteration_budget INTEGER NOT NULL DEFAULT 80,
    last_error TEXT,
    approval_request_json TEXT,
    parent_task_id INTEGER REFERENCES agent_tasks(id)
);

CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status);
CREATE INDEX IF NOT EXISTS idx_agent_tasks_source_origin ON agent_tasks(source, origin_ref);

CREATE TABLE IF NOT EXISTS agent_task_steps (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id INTEGER NOT NULL REFERENCES agent_tasks(id),
    ts TEXT NOT NULL,
    kind TEXT NOT NULL,                   -- 'tool_call'|'tool_result'|'model_text'|'approval'|'system'
    name TEXT,                            -- tool name for tool_call/tool_result
    args_hash TEXT,
    payload_json TEXT NOT NULL,
    latency_ms INTEGER
);

CREATE INDEX IF NOT EXISTS idx_agent_task_steps_task_id ON agent_task_steps(task_id, id);

CREATE TABLE IF NOT EXISTS agent_resource_locks (
    resource TEXT PRIMARY KEY,
    task_id INTEGER NOT NULL REFERENCES agent_tasks(id),
    acquired_at TEXT NOT NULL
);
```

### 3.2 New module: `scripts/agent_tasks.py`

**Functions:**

- `create(source: str, origin_ref: str, goal_text: str, allowed_tool_names: list[str], iteration_budget: int = 80) -> int` — inserts a new row, computes `resource_locks` from union of locked resources across the named callables (consulting registry for `lock_on` rules), assigns a `handle` unique within `(source, origin_ref)` among non-terminal tasks, returns task id.
- `get(task_id: int) -> dict`
- `list_active(source: str | None = None, origin_ref: str | None = None) -> list[dict]`
- `update_status(task_id: int, status: str, error: str | None = None) -> None`
- `append_step(task_id: int, kind: str, payload: dict, name: str | None = None, args_hash: str | None = None, latency_ms: int | None = None) -> None`
- `load_messages_for_runner(task_id: int) -> list[dict]` — rehydrate the OpenRouter `messages` array from `agent_task_steps` rows.
- `set_approval_request(task_id: int, request: dict) -> None`
- `resolve_approval(task_id: int, decision: str, note: str | None = None) -> None` — appends an `approval` step, clears `approval_request_json`, sets status back to `running`.

### 3.3 New module: `scripts/agent_locks.py`

```python
def try_acquire_locks(task_id: int, resources: list[str]) -> bool:
    if not resources:
        return True  # fast path: no locks, no contention
    with db.transaction("IMMEDIATE"):
        held = db.query(
            "SELECT resource FROM agent_resource_locks WHERE resource IN (?)",
            resources,
        )
        if held:
            return False
        for r in resources:
            db.execute(
                "INSERT INTO agent_resource_locks(resource, task_id, acquired_at) "
                "VALUES (?, ?, ?)",
                r, task_id, now_iso(),
            )
        return True

def release_locks(task_id: int) -> None:
    db.execute("DELETE FROM agent_resource_locks WHERE task_id = ?", task_id)

def held_by(task_id: int) -> list[str]:
    return [row[0] for row in db.query(
        "SELECT resource FROM agent_resource_locks WHERE task_id = ?", task_id
    )]
```

### 3.4 New module: `scripts/agent_runner.py`

**Tick entry point:** `tick(max_concurrent: int) -> None` — called by soul_engine on its existing cadence.

```
tick():
  running_count = count(status='running')
  available_slots = max_concurrent - running_count
  if available_slots <= 0: return

  candidates = list(status in ('queued','running'), order by created_at)
  for task in candidates:
    if available_slots <= 0: break
    if task.status == 'awaiting_approval': continue
    if task.status == 'queued':
      if not try_acquire_locks(task.id, task.resource_locks):
        continue
      update_status(task.id, 'running')
    run_one_tick(task)
    available_slots -= 1
```

**`run_one_tick(task)`:**

1. Build the `messages` array via `load_messages_for_runner(task.id)`.
2. Construct `ToolRuntime(source="agent_runner")`.
3. Filter the global callable manifest to the task's `allowed_tool_names` (stored on creation), plus the built-in `request_approval` meta-tool.
4. Call `llm.call_llm_chat_with_tool_loop(messages, tools, runtime=runtime)`.
5. For each new step the loop produces, call `agent_tasks.append_step(...)`.
6. If the loop returned because the model called `request_approval`:
   - Call `agent_tasks.set_approval_request(task.id, request_payload)`.
   - `update_status(task.id, "awaiting_approval")`.
   - Insert `mind_queue` row with `kind="agent_approval"`, payload includes `task_id`, `handle`, `request_payload`.
   - **Do not release locks** — task still owns them while awaiting approval.
   - Return.
7. If the loop returned a final assistant text:
   - Append a `model_text` step.
   - `update_status(task.id, "done")`.
   - `release_locks(task.id)`.
   - Insert `mind_queue` row with `kind="agent_complete"`, payload includes summary text.
   - Return.
8. If iteration budget exhausted without final text:
   - Persist progress, status stays `running`. Locks stay held. Return.
9. On exception:
   - `update_status(task.id, "failed", error=str(e))`.
   - `release_locks(task.id)`.
   - Insert `mind_queue` row with `kind="agent_failed"`.
   - Return.

### 3.5 The `request_approval` meta-tool

**Not** a plugin. Provided by ToolRuntime/agent_runner directly. Tool schema:

```json
{
  "type": "function",
  "function": {
    "name": "request_approval",
    "description": "Pause the task and ask the user to approve a destructive or irreversible action before proceeding. Use this before any operation that cannot be undone.",
    "parameters": {
      "type": "object",
      "properties": {
        "summary": {"type": "string", "description": "One sentence describing what you want to do, in plain language for the user."},
        "details": {"type": "object", "description": "Structured details about the action (e.g. {to_delete: [...], count: 23})."}
      },
      "required": ["summary"]
    }
  }
}
```

Only injected into the tools list when **at least one** of the task's allowed callables has `requires_approval: true`. The agent_runner detects calls to this tool and routes through the approval flow rather than executing it as a normal callable.

### 3.6 Wire into `scripts/soul_engine.py`

Add an `agent_runner.tick(AGENT_RUNNER_MAX_CONCURRENT)` call to the existing tick loop. Cheap when the queue is empty (one indexed SELECT). Config knob `AGENT_RUNNER_MAX_CONCURRENT` defaults to **3**.

### 3.7 Wire into `scripts/telegram_listener.py`

**Two new code paths:**

**A. Task creation routing.** After micro-brain returns its routing flags, check for a new flag `route_agent_task: bool` (added to micro-brain's JSON output prompt — see 3.8). If true:
1. Determine `allowed_tool_names` — for now, **all installed callable plugin tools**. (Future: micro-brain can narrow this.)
2. Call `agent_tasks.create(source="listener", origin_ref=str(chat_id), goal_text=user_message, allowed_tool_names=...)`.
3. Reply inline with a short acknowledgment ("On it — I'll work through that and check back when I have something."). Do **not** run the listener tool loop for this turn.
4. Return.

**B. Approval reply routing.** Before micro-brain runs, check `agent_tasks.list_active(source="listener", origin_ref=str(chat_id))` for any task in `awaiting_approval`:
- If exactly one → if user message looks affirmative/negative (simple keyword check first; if ambiguous, fall through to micro-brain), call `agent_tasks.resolve_approval(task.id, decision)` and reply with a short confirmation. Return.
- If multiple → invoke a small micro-brain disambiguation call (`source="micro_brain_approval_routing"`) that takes the list of pending approvals and the user message, returns a `task_id` or `null`. If null, ask the user to clarify by handle.

### 3.8 Micro-brain extension

Add `route_agent_task: bool` to the micro-brain inbound classifier JSON schema in `scripts/micro_brain.py`. Update its system prompt to instruct: "Set `route_agent_task: true` when the user is asking for a multi-step task that will likely require many tool calls and may take time (e.g. cleaning an inbox, researching a topic across many sources, generating and reviewing many files). Set false for short questions, single tool calls, and conversation."

### 3.9 Voice Dispatcher integration

`voice_dispatcher.py` already consumes `mind_queue`. Add handling for three new `kind` values:

- `agent_approval` — compose a Telegram message that includes the task's `handle` if more than one approval is currently pending across all sources, otherwise the handle can be omitted. Use the `summary` from the request payload.
- `agent_complete` — compose a "done" message using the summary text.
- `agent_failed` — compose a brief failure notice.

### 3.10 CLI surface

Add to `furoshiki` CLI:

- `furoshiki agents` / `furoshiki agents list` — list active agent tasks (filter by status).
- `furoshiki agents view <id>` — full row + recent steps.
- `furoshiki agents cancel <id>` — sets status to `cancelled`, releases locks, enqueues `mind_queue` notice.
- `furoshiki agents locks` — show current `agent_resource_locks` table.

### 3.11 Tests

- `tests/test_agent_locks.py` — empty fast path, single lock, multi lock atomic acquire, contention rejection, release.
- `tests/test_agent_tasks.py` — create computes correct resource set from registry, handle uniqueness, status transitions, message rehydration round-trip.
- `tests/test_agent_runner.py` — full tick with a fake tool loop: queued → running, approval flow, completion, failure, lock release on each terminal state, concurrent task scheduling respects max.
- `tests/test_listener_agent_routing.py` — task creation path, approval reply resolution (single and multi pending).

---

## Phase 4 — Dashboard surface

Add a new dashboard page **Agents** with:

- **Active tasks table:** id, handle, source, status, goal_text (truncated), iteration_count, locks held, age.
- **Step log viewer** for a selected task (paginated by step id).
- **Cancel** button per task (calls the same path as `furoshiki agents cancel`).
- **Resource locks panel:** read-only view of `agent_resource_locks` with a manual force-release button per row (operator escape hatch — logs to `system_events` with `kind=lock_force_released`).

Backed by new endpoints under `/dashboard/api/agents/*` using the existing bearer token pattern.

---

## Configuration knobs

Add to the env/settings doc:

| Variable | Default | Purpose |
|----------|---------|---------|
| `FUROSHIKI_AGENT_RUNNER_MAX_CONCURRENT` | `3` | Max simultaneously running agent tasks system-wide |
| `FUROSHIKI_AGENT_TASK_DEFAULT_BUDGET` | `80` | Default total iteration budget per task (model rounds across all ticks) |
| `FUROSHIKI_AGENT_RUNNER_ROUNDS_PER_TICK` | `8` | Max model rounds within a single tick before yielding |

These all live in the same place existing knobs do (Tier 1 operator config / `.env`, per `SETTINGS-CONSTANTS-AUDIT.md`).

---

## Documentation touch list

- **New: `docs/AGENT-TASKS.md`** — full design, lifecycle, approval contract, scheduler interaction, resource model, examples.
- **`docs/DELEGATION-AND-SKILLS.md`** — add a section distinguishing **delegation** (out-of-process coding work, existing) from **agent tasks** (in-process long-horizon tool sequences, new). Update the callable skills sequence diagram to show ToolRuntime in the loop.
- **`docs/PLUGIN-SPEC.md`** — new "Resource declarations" section: core vocabulary table, namespacing rule, validation behavior, `mode` field, `requires_approval` field, examples.
- **`docs/SELF-AWARENESS-DESIGN.md`** — add `agent_runner` to the process/tick diagram alongside Brain and Doctor.
- **`docs/INTEGRATION.md`** — how a non-Telegram harness creates and observes agent tasks via `agent_tasks.create()` and reads `mind_queue`.
- **`docs/MODELS.md`** — note that the `agent_runner` source uses the same DEEP model as the listener tool loop, governed by `ToolRuntime`.
- **`docs/SETTINGS-CONSTANTS-AUDIT.md`** — add the three new env vars.

---

## Order of implementation (recommended)

1. **Phase 1** end to end (ToolRuntime + wiring + tests). Ship and verify the listener loop still works exactly as before. This is purely additive — no behavior change unless `runtime` is passed.
2. **Phase 2** end to end (registry + validation + plugin manifest extension + tests). At this point no plugins yet declare resources, but the loader is ready.
3. Update any existing callable plugins to declare their `resources` field. They will not load otherwise once Phase 3 starts depending on the field.
4. **Phase 3** schema migration first, then `agent_tasks.py` + `agent_locks.py` + `agent_runner.py`, then `request_approval` meta-tool, then soul_engine wiring, then listener wiring, then voice_dispatcher, then CLI. Tests at each step.
5. **Phase 4** dashboard page.
6. Documentation pass last, after the code is settled.

---

## Acceptance criteria

The implementation is complete when all of the following are true:

1. The user can send "check my email and get rid of any junk mail" to the Telegram listener (assuming gmail callable plugins exist with appropriate resource declarations) and:
   - The listener replies with an acknowledgment within one turn.
   - A row appears in `agent_tasks` with status `queued`, then `running`.
   - `agent_resource_locks` shows `gmail` held by that task id.
   - Multiple ticks of `agent_runner` produce visible steps in `agent_task_steps`.
   - When the model decides to delete, a `request_approval` step appears, status becomes `awaiting_approval`, and a Telegram message arrives via Voice Dispatcher.
   - User reply "yes" resolves the approval, the task resumes, completes, and posts a final summary.
2. While the above task is running, a second unrelated task ("what's on my calendar tomorrow") can be created and runs to completion **in parallel**, holding only `gcal`.
3. A second gmail-touching task created while the first is still running stays `queued` until the first releases `gmail`, with no errors or deadlocks.
4. A plugin declaring `resources: [Gmail]` (wrong case) fails to load with a clear error pointing at the registry.
5. A plugin declaring `resources: [plugin:other_plugin:foo]` (cross-namespace) fails to load.
6. The existing listener tool loop (short, single-turn tool calls) continues to work unchanged for non-agent-routed messages.
7. All new tests pass; no existing tests regress.
8. `furoshiki agents list` and the dashboard Agents page both show live state.

---

## Out of scope for this change

- **Per-tool-call lock scope.** Locks are per-task lifetime only. Do not implement fine-grained acquire/release per round.
- **Op vocabulary beyond read/write.** Two modes only. No per-op enum.
- **Resource alias matching.** Canonical strings only; no fuzzy or alias resolution.
- **Promoting plugin resources to core.** Manual registry edit only; no automated promotion process.
- **Planner/critic agent loops.** The OpenRouter native tool loop is the only planning mechanism. Do not add a separate planner pass.
- **Cross-source approval routing.** Approvals only resolve within their own `(source, origin_ref)` scope.
- **Reader/writer locks beyond the simple `lock_on` config.** No upgrade/downgrade, no shared-read counting. If `lock_on` excludes a mode, that mode simply does not lock at all.
- **Agent task subtasks (`parent_task_id`).** The column is reserved in the schema for future use; do not implement spawning logic in this change.
