# Why only one loop may think

Exactly one function decides what to do about an inbound message — `runner.process_message` — and exactly one caller reaches it, `runner.think_pending`. Every other loop in the application fetches, bridges or drains, then stops. That boundary is what makes a per-workspace lock and a plan allowance mean anything: both are applied once, in one place, and no second loop can route around them.

- **Status:** Available
- **Audience:** both, developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/research/single-thinker/

## What a second thinker costs

The failure is not a crash. Two loops reading the same unhandled message both find work to do, and both do it — so a customer receives one reply twice, or two different replies, or a person opens Needs You to find two drafts answering one enquiry. Nothing errors, because from each loop's own point of view it did exactly what it was asked.

**Duplicate outbound** — The most expensive shape: the far side of the conversation sees the mistake before anyone here does
**Split accounting** — Two model calls where the plan sold one, or a reservation taken twice against a daily allowance
**Contended rows** — Two writers racing the same `processed` flag and attempt counter, so the retry ceiling stops meaning what it says
**An unprovable audit** — The decision log records the rule that applied, and two applications of the same rule to one message is not a story anyone can reconstruct later

## One entry, one lock, one reservation

`think_pending` is the single door. It resolves the workspace in scope, takes that workspace's lock without blocking, selects unhandled inbound messages oldest-first under a limit, and reserves plan capacity before any model is called. A caller that finds the lock held gets zero back and goes away; it does not queue, and it does not wait.

| Control | Where it sits | What happens when it refuses |
|---|---|---|
| Per-workspace lock | Acquired non-blocking at the top of `think_pending` | Returns zero handled; the next tick tries again |
| Plan reservation | Taken per message before the model is called | The message stays unhandled and the refusal is visible rather than silent |
| Attempt ceiling | `MAX_PROCESS_ATTEMPTS`, three | The message is held with a reason a person can read, instead of being retried forever |
| Ordering | Oldest first, by the time the message was sent | Nothing; it is what stops a busy workspace starving its own backlog |

The Owner has no plan record at all, and the reservation path allows that case explicitly rather than treating a missing plan as a refusal. That is the one commercial difference between the two audiences, and it lives in the metering call rather than in a second code path.

## What the other loops do instead

The tenant tick is the loop most likely to be mistaken for a thinker, because it runs constantly and touches mail. It does not decide anything: it fetches from the provider into the `tenant_*` tables and bridges into the canonical `threads` and `messages` through `connect_core.bridge_*`. The engine never reads the provider tables, which is exactly why adding a provider changes nothing downstream — and why the tick has no business drafting a reply.

- **A webhook** records an event and returns. A carrier or a messaging provider is waiting on that response, and thinking inside it would put a model call on a caller's critical path.
- **A manual sync** does what the tick does, on demand, for one mailbox.
- **`chase_phone`** drains due phone follow-ups, two per tick, but it places calls through the same gates as any outbound call rather than deciding anything new.
- **The Assistant** acts on a person's instruction with tools that call the same domain services; it is a different entry point for a person, not a second engine.

## Where the boundary stops holding

The lock is an in-process object, so it serialises one workspace's work inside one process and says nothing about a second one. What actually makes a second process harmless is the row: a message is selected only while it is still unhandled and under the attempt ceiling, and the plan reservation is taken before the model call rather than after it. The lock is the cheap guard; the durable state is the real one.

- A crash between reserving and finishing leaves an attempt spent. That is the deliberate trade — three attempts, then held with a reason — because the alternative is a message that is retried forever.
- One function holding triage, grounding, drafting, guarding and deciding is long on purpose: splitting it hides the order those must happen in, and the order is the part that matters.
- This says nothing about how many messages per tick is right. The limit is a tuning parameter, not a correctness boundary.

## Questions

### Why a non-blocking lock rather than a queue?

Because a caller that waits is a caller that is holding a request, a connection or a tick open. Returning zero and letting the next tick try is cheaper and self-correcting: the work is still there, still oldest-first, and the loop that already holds the lock is doing it.

### Does the Assistant bypass this?

No. Its tools call the same services the engine calls, and its rights are deliberately narrower than a person's — it cannot set pricing and cannot clear a do-not-contact entry. What it does not have is a second route to the decision loop.

### How would you notice a second thinker had appeared?

By the conversation, before any metric: the same reply arriving twice on one thread. In the record it shows as two decisions against one inbound message in the audit trail — see [What Connect may do](/docs/autonomy/) for how a single decision is supposed to read.

## Related

- [A background thread that outlives its task](https://connectbyjbrh.com/research/background-threads/)
- [Why an agent's tools should call domain services](https://connectbyjbrh.com/research/agent-tools-call-services/)
- [What Connect may do](https://connectbyjbrh.com/docs/autonomy/)
- [Why uncertainty is a valid answer from a business agent](https://connectbyjbrh.com/research/uncertainty-is-an-answer/)
- [Email in Connect](https://connectbyjbrh.com/docs/email/)

## What this page is based on

- `backend/app/runner.py` — `process_message`, `think_pending`, `think_lock`, `MAX_PROCESS_ATTEMPTS` and the plan reservation
- `docs-source/sources/GENERAL.md` §2, §4 and §7 — the request flow, the agent loop and `chase_phone`
- `docs-source/sources/CHANNELS.md` §1 — the two mail models and `connect_core.bridge_*`
