0077 — Agent Force is the dispatcher: an @-mention of it makes ONE pick of the driver, and both seats work the ask
- Status: Accepted 2026-10-30. Slices land one PR at a time (§4); this record is written with the first dispatcher slice, as ADR-0004 requires.
- Date: 2026-10-30
- Deciders: Gera, 2026-10-30, in three rulings relayed to the implementing lane: an @-mention of a bot user named "Agent Force" triggers exactly the
agents:path; then Agent Force is a real dispatcher, an app configured like Smith's (socket mode,app_mention), not a passive name; then "the idea of Agent Force is to invoke or dispatch BOTH of them to start working together." The dispatch post's shape (a visible line, not a reaction) was his call the same day: Agent Force should visibly BE the dispatcher. - Systems:
- agent-force-dispatch (agent-harness
agent_force_dispatch, launchdco.propflow.agent-force-dispatch): new entry inSYSTEMS.md. - team-protocol (agent-harness
agents_ask,slack_socket._answer_agents_ask): gains the@Agent Forcedoor and the teammate turn.
- agent-force-dispatch (agent-harness
- Relation: extends ADR-0071 (one driver per thread, turn-taking on
working/final). ADR-0002 is untouched: this is Slack-side seat coordination, not agent↔agent messaging between sessions. ADR-0003: the dispatcher is a rung-1 reflex daemon; it and the seats integrate by a marked Slack post, never by a call.
1. The decision
@Agent Force <question>is an ask of every agent in the channel, and the ONLY door (amended 2026-10-30, below). A person's message that @-mentions the Agent Force bot user is admitted byagents_ask.mention_ask, with the mention stripped from the question, in channels and threads. Agent Force's uid is found by name (users.list) with anAGENT_FORCE_USER_IDoverride; unfound, the mention means nothing.- The pick is one function.
thread_driver.ask_pickholds the rules (a seat the question names alone; the thread's driver; a sticker on the thread's root; the area rule on the asker), and every caller (both seats, the dispatcher) runs it. Membership follows: a pick outside the channel goes to a seat that is in it. - Agent Force makes the pick; the seats obey it. The dispatcher (Agent Force's own Socket Mode connection, its own tokens) receives
app_mention, runs the pick, and posts ONE thread reply under the ask:<@driver> drives · <@other> adds after, carrying metadataagent_force_pick {ask_ts, asker, driver}. On an@Agent Forceask each seat waits a bounded ~8 s for that post and takes its role from it. - A missing dispatcher degrades to the seats' own pick, never to silence. No pick post within the wait (the daemon is down, its tokens unset, or the seat is on code without the wait) → the seat runs the same
ask_pickitself, exactly asagents:does. This is one path with a bounded wait, not a switch: the seats cannot know whether the dispatcher is up (Trinity's seat is another machine), and a configured "dispatcher on" flag would be a second driver of the same decision. - Both seats work the ask. The driver answers. The other seat, once the driver took it, waits for the driver's answer to turn
final(ADR-0071 turn-taking, bounded by the same 180 s) and then takes ONE turn whose brief quotes that answer and asks only for what improves it: a correction, a missed risk, a missing step. It never restates the answer (one voice per point), @-mentions the driver only for a correction, and abstains when it has nothing to add. (As first written,agents:and/agentskept one answer and a 👀; both are retired, see the amendment.) - Agent Force never answers a question and is never a seat. It is not on the seat roster, it is removed from every seat set the pick sees, and its only post is the dispatch line.
2. Why this shape
- Why a post and not a reaction. A driver sticker placed by Agent Force would be read by
thread_driver.decideas a hand sticker, indistinguishable from Gera's own re-sticker, and a reaction cannot carry which ask it decided. A marked post is attributable (its author is Agent Force's uid, its metadata only an app can attach), names the ask, and is what Gera asked to see. - Why the seats still carry the pick. The ask must be answered when the dispatcher is not there: tokens not yet stored (the state on the day this was written), the daemon restarting, or a seat on older code. Keeping
ask_pickin the seats and making the dispatcher's post the thing they wait for means the worst case is theagents:behaviour plus ~8 s, never no answer and never two drivers deciding differently from the same text. - Why the teammate waits for
final. Contributing over a stub is the defect ADR-0071 exists to end (each seat answering the other's unfinished message). Quoting the final answer into the brief gives a top-level ask's turn the driver's words, which_dispatch_triggerdoes not otherwise attach (it reads a thread only for a reply).
3. Consequences
- A new always-on daemon on the mini (
co.propflow.agent-force-dispatch), KeepAlive likeco.propflow.smith-slack-socket. Without tokens it idles rather than exits, so a missing secret is one log line, not a respawn loop. - Each seat resolves Agent Force's uid with its OWN token and needs
users:read. Smith has it; Trinity's grant is unverified, andAGENT_FORCE_USER_IDon her host is the fallback. - Stated gap: Trinity's seat runs on Fede's machine; until it pulls main, an
@Agent Forceask there is an ordinary channel message for her, and Smith answers any ask picked for her after the owner window.
4. Slices
| Part | What | Repo |
|---|---|---|
| 1 | the @Agent Force door, uid by name, never a seat |
agent-harness #1752 |
| 1b | the teammate turn after the driver's final (§1.5) |
agent-harness |
| 2a | the dispatcher daemon, dark: receives the mention, logs its pick; ask_pick hoisted |
agent-harness |
| 2b | the dispatch post, and the seats' bounded wait for it (§1.3-1.4) | agent-harness |
Amendment 2026-10-30: @Agent Force is the one door
Gera, the same day, after the first live test: "That was just like a hack. I really like the Agent Force idea a lot better." The /agents slash command (2026-10-28) and the agents: message prefix (its in-thread stand-in) are retired; @Agent Force is the one way to call in the agents. Two doors to one engine would be a switch, and a fix is one path, never a switch.
- The shared engine stays (the pick, membership, the owner window, the late re-read, the teammate turn), and every ask now gets the teammate turn.
- A
/agentsthat Slack still delivers, until the command is deleted in Smith's app config, is answered with ONE ephemeral line pointing at @Agent Force: never a crash or a silent drop. - Old
/agentsthread roots remain in history: they are still refused as sibling handoffs and still read as their asker's, so those threads keep their driver. - Implementation: agent-harness #1757.