Agent dispatch unification
Status (2026-08-29): Phases 0, 0.5 and 1.5 are deployed and verified live — the
controller is now a headless dispatcher (typed command API + SSE event stream on :9101)
and IRC runs as the separate clients-irc adapter (store-authoritative flip 2026-08-24,
IRC-ectomy cutover 2026-08-26, both merged to main; adapter 1/1, stable). Phase 1’s
per-task worker shape was verified through the new API. Phases 3–5 (the Slack-facing
work) remain — Phase 2 is reframed as the controller’s own native interface, largely
already built as that command API. Consolidates the two parallel agent-orchestration
stacks (IRC and Slack) onto the one that already has the right shape, and unpins the worker
runtime from Anthropic so “provider-agnostic” is true in fact rather than in intent.
Shipped and proven. Provider unpin ran end to end on both OpenRouter and Anthropic
(real PRs); the store mirrors IRC (drift-clean) and typed :9100 ingest is the sole
worker→controller path. Getting there cleared two deploy blockers not in the original
plan — a vibes/-namespace image-name mismatch that silently defeated auto-pin, and an
uppercase-task-id bug that made every pvc-task-<id> an invalid K8s name (so no worker
had ever actually spawned). Remaining in 0.5: flip AGENT_STORE_AUTHORITATIVE — gated
now on a tested .db restic restore (the repair subcommand was dropped; see the
decision log), not more code.
One open blocker before Phase 4 can be designed: whether pi-mom’s Socket Mode
client surfaces block_actions. It cannot be answered from source — see Open
questions for the one-line check against the running pod. Phases 0–3 are unblocked.
Phase 0.5 (added 2026-08-23) gates Phases 2–5. A source review found that “the channel is canonical” — the record model every later phase adds writers and volume to — does not hold: registered task channels accumulate against a 15-channel cap with no error path, history expires in a week, and the firehose both derives from chat prose and silently drops its own oversized events. The controller gets a real state store and IRC becomes a projection. See Phase 0.5 for the evidence and the change.
Sequencing revised 2026-08-23 on the finding that the IRC stack has no users (zero task channels in ergo). IRC stays a supported client option, but the original order was built to protect a running system that isn’t running. Phase 0.5’s dual-write evidence period is dropped, a new Phase 1.5 decouples IRC from the controller while that is still cheap, and Phase 2’s proxy design is superseded by it. Numbers are kept, so they no longer read in execution order — see the table at the top of Sequencing.
Depends on: irc-ai-agents steps 3–10 (controller, worker, firehose,
dashboard) — all shipped. The base/agent-wrapper bump to pi-mono 0.73.1, already
executed per vibes/irc/pi-mono-update-plan.md.
Blocks: simplifies plan-agent-workspace-renderer.md Phase 4 substantially — if
Slack work becomes controller-dispatched work, the firehose already carries it and no
wrapper emitter or backend ingest path is needed.
Background: irc-ai-agents (the IRC stack’s design of record);
plan-agent-task-console.md (specs the write-proxy and flags the attribution
problem this plan inherits); slack/AGENTS.md + slack/docs/architecture.md;
comparison of the munder-difflin, pi-mom and agent-controller orchestrators,
2026-08-22. turnstone/channels/slack/ is the working precedent for every
interactive mechanic this plan needs — see “Slack can carry the full workflow”
below.
Motivation
We run two orchestration stacks for the same job, and only one of them is built the way we’d choose on purpose.
The IRC stack already has the layering we want: a deterministic Go dispatcher
(agent-controller) that owns scheduling, state, and the only K8s credentials in the
namespace; a TypeScript pi-mono harness (base/agent-wrapper) that does the work
inside an ephemeral per-task pod; and ergo as the durable record. No LLM sits in the
dispatch decision, and the whole thing is unit-tested.
The Slack stack duplicates all of that badly. pi-mom is router and dispatcher
and Slack-token holder: its forward_* tools spawn a detached worker.py that
POSTs to a long-lived agent Deployment. The consequences are concrete:
- No record. A forward returns “Request forwarded…” and pi-mom never learns whether it succeeded. There is no ledger, no status, no completion signal — half the fleet is invisible to every dashboard we have.
- State dies on restart. Agent conversation state is per-channel and in-memory
in the wrapper (
base/agent-wrapper/src/index.ts), so a rollout loses a multi-turn task mid-flight.slack/docs/architecture.mdsays as much. - Concurrent threads collide. That in-memory state is keyed by
channel_id, so two Slack threads in one channel share a conversation. - Two of everything. Two authz models (pi-mom’s deny-by-default capability ACL vs. the controller’s none), two attribution schemes, two dispatch paths, two worker lifecycles.
Meanwhile the IRC stack has its own defect that cuts directly against the direction
we want to go: the worker runtime is Anthropic-only by construction.
internal/bot/models.go hard-codes a three-entry Claude menu, spawn.go:248 sets
LLM_PROVIDER to the literal "anthropic", and ANTHROPIC_API_KEY is a required
secret ref. Routing more work onto that runtime today would migrate it onto a
narrower LLM surface than it is leaving.
Current state
The two stacks
| IRC | Slack | |
|---|---|---|
| Dispatcher | agent-controller (Go) — scheduling, state machine, k8s | pi-mom (patch-tools.js → detached worker.py) |
| Routing decision | deterministic: oldest ready first, under MaxActiveTotal (default 3) | an LLM picks a forward_* tool from a closed list |
| Worker | ephemeral Job + PVC per task; no cluster credentials | long-lived Deployment per capability; in-memory per-channel state |
| Harness | base/agent-wrapper (TypeScript, pi-mono) | same image, same packages |
| Record | ergo: topic + METADATA + CHATHISTORY, plus #firehose | {channel}/log.jsonl on a PVC. No task state |
| Authz | none — any IRC user can !new | /etc/pi-mom/acl, deny-by-default, capability → global|channel:<id> |
| Completion signal | notifyRequester() DMs on for_review/blocked/done (bot.go:376) | none |
The Go/TypeScript boundary already exists — on the IRC side
ergo (record) ← agent-controller (Go: dispatch, state, k8s) → agent-worker pod └─ base/agent-wrapper (TypeScript, pi-mono)This plan does not invent that split. It makes Slack a client of it.
Facts that constrain the design
main.go: “it connects out to ergo (no inbound listener).” The controller has no HTTP surface at all today, by stated design.- The controller is the only component in
vibeswith K8s API access. Workers carry no cluster credentials. Its blast radius is the whole namespace. bot.New(cfg, conn, spawner)is rebuilt on every reconnect (main.goloop); state is rebuilt from IRC each time. Anything long-lived must not hold a*Bot.agent-controller reaperis already a subcommand run as a separate workload with its own RBAC, precisely because the controller SA lacks PVCdelete. The pattern for “same codebase, different privileges, separate process” is established.WORKER_POD_TEMPLATEis controller-level config (spawn.go:130) — one pod shape for every task.GH_TOKEN/GIT_TOKENcome from one sharedAgentSecretName(spawn.go:255).bot.go:630setsRequester = sender, so anything filed by a proxy identity readsrequester = <proxy>. This is the open questionplan-agent-task-console.mdalready flagged, with a proposed trusted[as <nick>]prefix.
Proposed target state
Three layers, and one invariant
| Layer | Language | Owns | LLM? |
|---|---|---|---|
| Clients — Slack app, relay-client, The Lounge, gamja | any | presentation | no |
Dispatcher — agent-controller | Go | scheduling, state machine, the task store, k8s API, IRC writes, #firehose | never |
Harness — base/agent-wrapper | TypeScript (pi-mono) | doing the work; provider choice | yes |
Invariant: nothing that talks to an LLM holds cluster credentials or owns state; nothing that owns state talks to an LLM.
Already true on the IRC side. Violated on the Slack side today, where pi-mom routes, dispatches, and holds the Slack token.
pi-mom becomes a translator, not an orchestrator
Natural-language intent routing is a client concern. IRC users type !new —
explicit, no model needed. Slack users type English, so that client needs an LLM to
turn English into a typed request. That is pi-mom’s entire remaining job:
Slack message → pi-mom (LLM) → { capability, message, requester, origin, thread } → dispatcher → result → pi-mom relays to the threadIt keeps its deny-by-default ACL (a client-side authz check is fine and it is the
only authz we have), its typed closed-list forward_* tools, and channel-tail
injection. It loses worker.py, the detached spawn, and any ownership of work.
LLMs end up only at the edges — one in the Slack translator, one in each worker — and the deterministic middle has none.
The dispatch API — a separate process, same codebase
The controller gains a typed API, but not on the k8s-privileged process. Following
the existing reaper pattern, a new agent-controller api subcommand runs as its own
Deployment with:
- no k8s RBAC at all — it never spawns anything;
- a registered ergo identity with write power;
- an inbound listener on a cluster-internal Service only, bearer-token authenticated, no Gateway route.
It translates typed requests into the IRC commands the controller already validates, so the controller remains the sole authority on state and the sole IRC writer of task status. The API process is a client with a nice interface, not a second brain.
Slack ─► pi-mom (TS, LLM) ─┐ ├─► agent-controller api ─IRC─► agent-controller relay-client ──────────────┘ (Go, no RBAC, (Go, k8s RBAC, (web console) ergo write identity) no inbound port) │ ▼ agent-worker pod (TS pi-mono harness)Surface, roughly:
| Method | Path | Effect |
|---|---|---|
POST | /v1/tasks | {repo?, description, as} → !new (+ !repo), returns task id |
POST | /v1/tasks/:id/ready | !ready |
POST | /v1/tasks/:id/actions | complete | reopen | kill | archive |
POST | /v1/tasks/:id/messages | PRIVMSG into the task channel (steering, clarification answers) |
POST | /v1/ask | ephemeral request — see below |
GET | /v1/tasks | snapshot (or defer to dashboard-backend, which already serves this) |
This is the same component plan-agent-task-console.md designed as console-backend.
Build it once, serve both clients. Two write-proxies with two attribution schemes
is the outcome to avoid.
Two request classes: task and ask
Skip the ceremony for turn-shaped work, never the record.
task | ask | |
|---|---|---|
| Shape | produces a reviewable artifact | a question with an answer |
| Lifecycle | pending → ready → in_progress → for_review → done | none |
| Execution | ephemeral Job + PVC per task | routed to a warm capability Deployment |
| Channel | #task-N / #<repo>-task-N | none |
| Slot | counts against MaxActiveTotal | does not |
| Record | full: channel transcript, METADATA, firehose | ask_started / ask_completed on the firehose |
| Examples | web-developer, popquiz | general (Prometheus, Matomo, Airtable) |
A cold pod per lookup is the wrong substrate — schedule + pull + clone to answer
“what’s the load on thoth?” Ephemeral pods are right for task work; ask routes to
the warm Deployments that already serve it. One dispatcher, two execution substrates,
chosen by work shape.
Work shape is a property of the request, not of the agent
A capability declares which shapes it supports; the caller picks one per request. Binding a capability permanently to one shape would be a modelling error — the same agent legitimately serves “check this page” and “audit the whole site”.
| Capability | Supports | Notes |
|---|---|---|
web-developer | task | Clones, edits, pushes a branch, opens a PR. Loses state on restart today |
popquiz | task | Same shape — edits app data and pushes |
accessibility-tester | both | An audit is a task: minutes of work, a durable WCAG report, and a for_review that genuinely means “a human read the findings”. A single-page spot-check is an ask. Task dispatch also reclaims its 2 CPU / 2 Gi standing reservation, which is idle almost always |
general | ask | Lookups and monitoring. Latency-sensitive, no artifact |
general is ask-only for now because nothing it does produces a reviewable
artifact — but that is an observation about its current skills, not a constraint.
The registry expresses it as a list, so it costs nothing to widen later.
Where things live — promoting the shared tier
The tree still says “two stacks” because until now it was true. A shared tier already
exists and works — base/agent-wrapper is a base-chain image under six agents, the
IRC worker and slack/bot, and agents/* sits at the root — so this is extending an
established pattern, not inventing one.
What stops being client-specific under this plan:
| Today | Becomes | Why |
|---|---|---|
irc/agent-controller/ | shared orchestration | Serves Slack from Phase 2; “irc/” becomes a lie |
irc/dashboard-backend/ | shared read model | Already generic — it renders an event contract, not IRC |
irc/agents-workspace*/ | shared client | Renders the firehose; nothing IRC about it |
[[agent]] blocks in vibes-slack.toml | shared capability registry | Two consumers from Phase 3 (pi-mom’s tools, the ask router), soon three |
| firehose event vocabulary | shared schema | Already the contract between four components |
What stays client-specific, correctly: slack/bot/ (pi-mom, its patches, mrkdwn
formatting), slack/channel-tailer/, irc/relay-client/ and The Lounge (they are
IRC clients), and ergo’s own config.
A plausible destination — orchestration/{controller,read-model,worker,registry,schema}
and clients/{slack,relay,workspace-2d,workspace-3d}, with agents/ and base/
unchanged. But see the sequencing rule below: the name change lands with the change
that makes it true, never before. Renaming irc/agent-controller while it is still
IRC-only buys nothing and costs a CI rewrite — the docker-images--vibes.yml bake DAG
is path-driven, image names embed vibes/irc/, and every
infra/k0s/vibes/*/kustomization.yaml pins digests by path.
Splitting the config: capability registry vs client scoping
vibes-slack.toml currently mixes two kinds of fact. Separate them by asking would
an IRC-only deployment need this?
Shared — the capability registry. What a capability is: id, description,
endpoint Service, worker pod template, secret name, default provider + model, and
shapes: [task, ask] per the section above. This is what the ask router resolves
and what per-task pod/secret selection (Phase 1) reads.
Client-specific — scoping and presentation. Which Slack channels may invoke which
capability (pi-mom-acl), the MOM_* toggles, TAILER_CHANNEL_IDS. Each client
scopes independently; the registry never mentions a channel id.
One thing to reconcile rather than sort: web-developer-channel-repos.yaml and
IRC’s !hub/!repo binding are the same concept implemented twice — “which repo
does this work target”. Under unification that should be one repo-binding model, with
a Slack channel being one way to default it, exactly as a repo hub defaults it on
the IRC side. Two sources of truth for a repo binding is precisely the class of
duplication this plan exists to remove.
Slack can carry the full workflow — turnstone already proves it
The obvious objection to routing task-shaped work through Slack is that the review
gate and permission prompts need interaction, and pi-mom’s Socket Mode has no inbound
port. That objection is already disproven in a repo we run.
turnstone/channels/slack/bot.py runs slack_bolt’s AsyncSocketModeHandler and
serves interactive Block Kit buttons over it — Slack delivers block_actions
payloads down the same socket, so no public Request URL and no inbound port are
required.
turnstone is reference only. It is not modified, not adopted, and not in the
dependency path of anything here — pi-mom remains the Slack front-end precisely
because it is ours to patch (four patches already, docs/pi-mom-patches.md). What
follows is a set of proven design answers to copy in TypeScript, not code to import:
| Mechanic | turnstone’s answer | What it solves here |
|---|---|---|
| Approve / Reject / Always Approve buttons on a tool call | Block Kit over Socket Mode | The [PERM id] gate, and for_review accept/reopen |
Static custom_ids, correlation (ws_id, correlation_id) carried out-of-band | buttons survive a bot restart | A naive build puts state in the button payload and loses it on every rollout |
Route key is a triple (channel, user_id?, thread_ts?), round-tripped to an opaque id and persisted in channel_routes | re-subscribed on restart | Directly fixes the thread-collision hazard below — the wrapper keys on channel_id alone today |
channel_users maps (channel_type, channel_user_id) → internal user; self-service /link via modal | real per-user identity | The attribution prerequisite in Phase 2 |
auto_approve_tools allowlist | per-tool auto-approval | Better than IRC’s all-or-nothing AGENT_REQUIRE_APPROVAL |
Also worth copying: only the user who owns the workstream may approve or reject, and only linked users can press the buttons at all.
So the review gate is not a reason to keep Slack file-and-forget. Accept/reopen and
[PERM] approve/deny can both live in the originating Slack thread, with the
completion signal that already exists (notifyRequester, bot.go:376) as the
trigger.
On message schemas: no to FIPA, yes to the instinct
munder-difflin’s FIPA-lite speech acts (request | inform | propose | query | agree | refuse | done, plus hops and requires_reply) are a good fit for munder’s
problem, which we do not have. Those fields exist to stop two peer agents
ping-ponging forever. In this design the topology is a star — clients → dispatcher →
worker — and workers are per-task, isolated, and never message each other. The
anti-livelock machinery would have nothing to prevent.
Nor is it meaningfully “more standard”: FIPA-ACL has been dormant since the mid-2000s and nothing in the current agent ecosystem implements it, so adopting it buys no interop, no libraries, and no tooling. The live standards are MCP (which turnstone already supports) and structured tool-calling.
But the instinct is right, and it points at a real problem. The IRC wire protocol
is an accreting bag of ad-hoc string markers — [status] X, [meta] k=v,
[PERM id] tool: preview, → tool, ✓/✗ — and relay-backend-plan.md proposes
adding [PERM id risk] … :: why and [ASK id] prompt || A) label :: desc on top.
That is drifting toward an unversioned protocol parsed by regex on both ends.
The typed schema to standardize on already exists: the #firehose event
vocabulary. It is versioned (v: 1), has a Go emitter and a TypeScript parser, and
three consumers. The rule going forward: new signals become firehose event kinds
with typed fields, not new in-channel string markers. The one idea worth borrowing
from FIPA is narrower than an ontology — that a message should declare whether it
expects a reply. That is one boolean on the clarification event, not seven acts.
(If peer-to-peer agent messaging is ever added, revisit. It is not in this design, and worker isolation is one of agent-controller’s better properties.)
Why this approach (and not the alternatives)
| Option | Verdict |
|---|---|
| Leave two stacks; give pi-mom its own ledger | rejected — a second orchestration lineage to build and maintain, and still no unified record |
| Clients speak IRC directly as a privileged proxy | considered — preserves “IRC is the API” exactly, but forces every client to be an IRC client, and puts the write identity in the LLM-holding process |
| A separate gateway service, unrelated to the controller | rejected — same shape as the chosen option, but a third codebase instead of a subcommand of the one that already owns the command grammar |
Typed API as an agent-controller subcommand | chosen — one command grammar, one attribution scheme, and no inbound port on the k8s-privileged process |
Honest cost: this breaks the stated invariant that all control happens via IRC
(dashboard-backend README; plan-irc-ai-agents §“Out-of-scope for the dashboard”).
Defensible — IRC becomes the substrate rather than the API — but it is a real
posture change, and the reason the API process gets no cluster credentials.
Sequencing
Execution order — revised 2026-08-23
The IRC agent stack has no users. A read-only LIST against ergo returned five
channels — #tasks, #firehose, and three seeded repo hubs — and zero task
channels. Since !archive never deregisters, any task ever filed would still have a
registered channel; there are none, and no pvc-task-* PVCs either. (A PVC reset would
wipe channels while SEED_REPOS recreates the hubs, so “never used” and “reset since
last use” are indistinguishable — but nothing is running now either way.)
IRC is nonetheless staying as a supported client option, so nothing below removes it; the IRC command surface is preserved in full, as an adapter.
That fact reorders the plan, because the original sequence was shaped by don’t break the running IRC system and there is no running IRC system. Phase numbers are kept so existing cross-references still resolve, so they no longer read in execution order:
| Order | Phase | Change from the original plan |
|---|---|---|
| 1 | 0 — unpin the provider | ✅ built (undeployed); independent of everything |
| 2 | 0.5 — store authoritative | ✅ built (undeployed); steps 1+2 merged: no dual-write waiting period |
| 3 | 0.5 step 3 — typed worker ingest | ✅ built (undeployed); prerequisite for the next line |
| 4 | 1.5 — IRC becomes a client | new; prerequisites now met, and at its cheapest |
| 5 | 2 — typed API | reframed: native interface, not an IRC proxy |
| 6 | 1 — per-task worker shape | unchanged; can land any time before 5 |
| 7 | 3 → 4 → 5 — ask, pi-mom, migrate | unchanged; the Slack-facing work |
Three consequences worth stating plainly, because each retires earlier advice:
- Phase 0.5’s dual-write evidence period is pointless here. Step 1 exists to prove the store agrees with IRC before reads flip. With zero tasks there is nothing to compare, and a week of drift logs would prove nothing. The mirroring code already written stays (it is how writes reach the store); the waiting goes.
- The decoupling will never be cheaper. Its risk was almost entirely “breaks production,” and there is no production. Every task filed from here raises the cost.
- Phase 2 as originally designed should probably not be built. Translating HTTP into
!commandstyped at a chat server exists to preserve an inbound interface with no users. Decouple first and the API is simply the controller’s own interface.
Preconditions
Host disk.Cleared 2026-08-23: the virtiofs mount backing/operator/{notes,repos,files}was 100% full on 2026-08-22 (461 G, ~285 M free) and a write there failed withENOSPC. It now reports 70% used / 143 G free. Nothing in this plan was ever blocked by the design; it blocked image builds,gitand Docker. Re-check before a build session, since it is unrelated to this work and can refill.- The one open blocker is
block_actionssupport in pi-mom (see Open questions). It gates Phase 4 only — Phases 0–3 can start without an answer.
Cross-cutting rule: directory moves ride the change that makes them true
A component moves when the change that makes it shared lands — never before, never
as its own commit. agent-controller moves in Phase 2 (when it starts serving
Slack), the registry directory is created in Phase 3 (when it gains a second reader),
the renderers move whenever plan-agent-workspace-renderer.md next touches them.
Each move costs the same four edits, so keep them small and attached to real work:
the docker-images--vibes.yml bake DAG (path-driven), the image name (which embeds
vibes/irc/), the infra/k0s/vibes/*/kustomization.yaml digest pin, and doc
cross-references. A big-bang reorg pays all of that at once for no functional gain
and makes every in-flight branch conflict.
Phase 0 — unpin the provider (early, and useful on its own)
Everything else moves work onto the worker runtime, so widen it before migrating onto it. Independent of Phase 0.5 — different files, no ordering constraint between them; this one is breadth, that one is foundation.
internal/bot/models.go—modelTiersbecomes provider-qualified;resolveModel()returns(provider, id)rather thanid.internal/spawn/spawn.go:248—LLM_PROVIDERfrom the spec, not a literal.spawn.go:250—ANTHROPIC_API_KEYbecomes a provider-dependent secret ref instead of a required one.MS Foundry and OpenRouter are OpenAI-compatible base URLs, so they need a base-URL env too.Corrected 2026-08-23 against pi-ai source: only Azure / MS Foundry needs endpoint config. OpenRouter is a first-class pi-ai provider carrying its ownbaseUrl(https://openrouter.ai/api/v1) and readingOPENROUTER_API_KEY— the key is all it needs. Azure wantsAZURE_OPENAI_BASE_URL, orAZURE_OPENAI_RESOURCE_NAMEto derive it, plus an optionalAZURE_OPENAI_API_VERSION.!modelgains provider-qualified choices;modelMETADATA already rides to spawn, so the transport exists.
Roughly 60 lines of Go plus secret wiring.
The harness side needs no work at all — verified 2026-08-22 against
/operator/repos/pi. Superseded 2026-08-23: true of pi-ai’s provider support,
false of the wrapper — base/agent-wrapper carried its own two-provider allowlist and
a silent wrong-model fallback. See “Harness side” under Phase 0’s status. The paragraph
below is still correct about pi-ai itself. pi-ai ships ~40 first-class providers in
packages/ai/src/providers/, including every one named as a requirement:
anthropic, openai, openrouter, and azure-openai-responses (id
azure-openai-responses, name “Azure OpenAI” — the MS Foundry path), plus
amazon-bedrock, google, google-vertex, groq, mistral, xai, deepseek,
cerebras, together, fireworks, nvidia, github-copilot and more. Each carries
its own env-var auth (AZURE_OPENAI_API_KEY, etc.) and its own model list.
So the Anthropic pin is entirely ours, and entirely in Go. This phase is smaller
and lower-risk than it looks: three edits in agent-controller plus per-provider
secret wiring. Nothing in the TypeScript harness changes.
The secret wiring is smaller than “per-provider secret wiring” suggests — checked
2026-08-23. The agent-worker ExternalSecret uses dataFrom: extract against
kv/eso/agent-worker, mirroring every kv field 1:1 into the Secret. So a new provider
key is make k0s-openbao-kv-patch SVC=agent-worker FIELD=OPENROUTER_API_KEY and a
matching binding in spawn.go — no manifest change. Two consequences:
- The ExternalSecret’s comment (“Keep this kv path to exactly the two fields below; anything put here lands in the Secret”) becomes wrong the moment a third key is added. Update it in the same commit, or the next reader trusts a stale invariant.
- An OpenRouter key already exists in the org — the
general-agentandturnstoneblocks ofinitial-kv-seeds.sops.yamlboth carryOPENROUTER_API_KEY. So the verify step below has a ready non-Anthropic target and needs no new vendor account.
One caveat inherited from Phase 1: AGENT_SECRET_NAME is a single shared secret today,
so any provider key added here is visible to every worker. Acceptable for a scratch
verification run; it is the same broadening Phase 1 exists to fix, so do not treat this
as the permanent shape.
One design note that follows: pi-ai maintains a generated model catalog
(models.generated.ts, model-catalog.ts). The hard-coded modelTiers table in Go
is a second, hand-maintained model list that will drift from it. Prefer keeping the
Go menu deliberately thin — provider + a short alias set — and let the worker
validate the model against pi-ai’s catalog at startup, rather than mirroring it.
Verify: run one scratch IRC task on a non-Anthropic provider end to end; confirm
!model menu, spawn env, and tool telemetry all behave.
Status: controller-side implemented 2026-08-23
models.go,bot.go,config.go,spawn.goand theagent-workerExternalSecret comment are done. A task now carries a (provider, model) pair — always set and read as a unit, so a task can never pair a model with a provider that doesn’t serve it.!modelkeeps the three Anthropic tier aliases and gains a<provider>/<model-id>escape hatch; the split is on the first/only, since OpenRouter ids contain one themselves. The allowlist (providersinmodels.go) isanthropic,openai,openrouter,azure-openai-responses. Only the selected provider’s credential is bound, and it is bound required, so a provider whose key is missing fails the pod rather than falling through to another provider’s creds. The credential sits in the exact slotANTHROPIC_API_KEYoccupied, so an anthropic task’s env is unchanged entry-for-entry. NewproviderMETADATA key;worker_spawnednow carriesprovider.Deliberately not mirrored: model ids are never validated in Go. The allowlist covers providers only — that is what picks a credential. This follows the plan’s own note about
models.generated.tsdrift.Verified:
internal/botbuilds and its full test suite passes, including 9 new provider tests (qualified choice, first-slash split, allowlist rejection, tier-choice resets provider, legacy model-only METADATA defaults, firehose field, menu). All changed files aregofmt-clean and parse.Fully verified 2026-08-23 on
moby@10.112.113.191(eren, Docker 29.5.1). The whole module graph resolves and the whole suite passes,internal/spawnincluded. Verification is just building the image:agent-controller/Dockerfilealready runsgo mod download,go vet ./...,go test ./...andgo build, and fails the image on any of them. All four passed —bot,spawnandreaperok, binary built — and the 13 new tests were re-run verbosely against the builder stage to confirm they execute rather than being skipped.The earlier go.sum checksum mismatch was this sandbox, not the repo. The same
go mod downloadthat fails locally ongithub.com/ergochat/irc-go@v0.6.0completes in 16s on a host with clean egress. Nothing is wrong withgo.sum; the local network is TLS-intercepted. Worth remembering the next time a Go or npm fetch fails oddly in here — and worth not working around withGONOSUMCHECKorGOFLAGS=-insecure.Harness side: two more pins, and a live bug — fixed 2026-08-23
The claim that “the harness side needs no work at all” was wrong, and finding out why turned up a production bug.
base/agent-wrapperhad its own provider pin and its own silent-failure mode:
- A second provider allowlist.
PROVIDER_KEY_VAR(agent.ts:21) listed onlyanthropicandopenrouter, andsetupProviderexit(1)s on anything else — so the controller’sopenaiandazure-openai-responseswould have been rejected by the worker. Replaced with aPROVIDERStable mirroring the Go allowlist, with a comment on each side pointing at the other. Azure also now fails at boot if it has neitherAZURE_OPENAI_BASE_URLnorAZURE_OPENAI_RESOURCE_NAME, instead of at first use.getModelsilently substituted a different model.agent.ts:43wasallModels.find(m => m.id === modelId) ?? allModels[0]— an unknown id ran the catalog’s first model with no warning. Now throws, listing the valid ids.index.tsresolves the model once at boot rather than per-request, so a badLLM_MODELis a failed start rather than a healthy container that dies on first request with/healthlying in the meantime.irc-task.tsneeded no change — itsmain().catchalready logs and exits 1.The live bug this exposed.
modelTiershadopus→claude-opus-4-8, which is not in pi-ai 0.73.1’s catalog (23 anthropic models; newest opus isclaude-opus-4-7). So!model opus— “most capable” — has been silently runningclaude-3-5-haiku-20241022, the catalog’s first entry and about the weakest model available. Every task that ever chose opus got 3.5 Haiku. Corrected the tier toclaude-opus-4-7and documented the check to run before bumping a tier id.haiku,sonnetand theWORKER_MODELdefault were all verified present.This is the argument for the fix in miniature: the fallback did not prevent a failure, it hid one for as long as the id was stale.
Verified against the real pinned deps (
npm ci, integrity checks passed):tsc --noEmitclean understrict; both esbuild bundles build; the bundled binary exits 1 with the valid-id list on a badLLM_MODEL; all four allowlisted providers resolve in pi-ai 0.73.1 (anthropic 23, openai 42, openrouter 275, azure-openai-responses 42); and every (provider, model) pair the controller can emit resolves in the worker.Blast radius.
base/agent-wrapperis a base-chain image under six agents, the IRC worker and slack/bot, so this rebuilds more than the controller does. It is a separate commit from the Go change for that reason. The behaviour change is deliberate: a container that would previously have run the wrong model now refuses to start. Anything currently relying on the fallback — i.e. running a model nobody chose — will surface as a boot failure naming the bad id. That is the point, but it should ship when someone can watch it.Remaining before the verify run: add
OPENROUTER_API_KEYtokv/eso/agent-worker(make k0s-openbao-kv-patch SVC=agent-worker FIELD=OPENROUTER_API_KEY); build and deploy the controller and the wrapper chain.
Phase 0.5 — give the controller a state store; IRC becomes a projection
Independent of Phase 0 — the two touch different code and can land in either order. Numbered 0.5 because it gates more: Phase 0 is breadth (a wider LLM menu, byte-identical by default), while this is the record model that Phases 2–5 add writers, volume, and a second work shape to.
Why it can’t wait. Four things verified in source on 2026-08-23 say the model does not hold:
| # | Finding | Evidence |
|---|---|---|
| 1 | The controller is founder of every task channel and !archive never drops the registration, so registered channels accumulate monotonically against a 15-channel cap the controller has no exemption from. Past it ChanServ REGISTER fails — silently, because the controller registers five callbacks (PRIVMSG, JOIN, 322, 761, 323) and never reads a NOTICE. ergo is set unregistered-channels: false, so that task gets no persistent history and no persisted METADATA: it lives only while a client is in the channel, then drops out of LIST and is gone on the next controller restart | ergo.yaml:235, opers: {}, bot.go:637, bot.go:1111, ircconn.go:99-125 |
| 2 | The record has a 1-week TTL and a 2048-message ceiling per channel | ergo.yaml history.restrictions.expire-time: 1w, channel-length: 2048 |
| 3 | The firehose — this plan’s “typed schema to standardize on” — is regex-scraped out of chat prose. The worker prints → bash: cmd / ✓ bash / [PERM id] tool: preview into the channel; the controller pattern-matches those lines and re-emits JSON | firehose.go:25-28, firehose.go:54-82 |
| 4 | Firehose events over 400 bytes split into two PRIVMSGs — two invalid JSON lines — and the reader drops both without logging. A tool_call_start with a 300-char args runs ~450 bytes; long git/gh/curl commands are the common case, so the shared contract already loses events today | ircconn.go:58, firehose.go:62, dashboard-backend/main.go:53 |
Finding 1 is the urgent one: the plan’s stated elegance — “the controller holds no
persistent store” (bot.go:5-9) — degrades to no store at all with no error path.
Finding 3 is the structural one. The rule adopted in “On message schemas” — new signals become firehose event kinds, not new in-channel string markers — cannot be followed while a worker’s only path to the controller is typing into a chat channel. The firehose does not replace the string protocol; it launders it.
The change. SQLite on a PVC mounted into the controller. Roughly 200 lines plus a migration. No new schema language and no new vocabulary — this is the existing firehose event contract given a durable home and a typed ingress.
Storage preconditions, checked against the live cluster 2026-08-23:
-
The controller has no PVC today —
agent-controller.yamlrunsreadOnlyRootFilesystem: truewith the comment “static Go binary; logs to stdout, writes nothing.” This phase creates one.readOnlyRootFilesystemcan staytrue: it governs the root filesystem, and a mounted volume remains writable. -
replicas: 1+strategy: Recreateare already set (“never run two controllers at once”), which is exactly what a single-writer SQLite on an RWO volume needs. No strategy change, and no new argument to win — the constraint is already the design. -
Node pinning is the real cost.
ergo-datais RWO onlocal-path, i.e. node-local; a controller PVC on the same class pins the controller to a node. ergo already has this property, so it is a precedent rather than a new class of problem — but it is a scheduling constraint the controller does not have today, and it belongs in the rollout notes. -
Tasks become rows rather than a channel plus a topic prefix plus METADATA keys: id, repo, status, title, description, requester, assignee, job, provider, model, timestamps.
setStatus(bot.go:343) becomes one transaction instead of a topic write plus N unacknowledgedMETADATA SETs that can half-apply across a disconnect. -
The event log becomes a table, and
#firehosebecomes a fan-out of it rather than its home. Consumers wanting history get a query; consumers wanting live get the channel, or SSE fromdashboard-backend, which already serves exactly this. Event size stops mattering — finding 4 becomes a rendering concern, not data loss. -
A worker → controller path that is not a chat line. The wrapper already speaks HTTP; the controller gains a cluster-internal ingest endpoint carrying the same
tool_call_*/status/meta/ permission events it currently reconstructs by regex. Channel narration stays — it is genuinely good — but becomes a rendering of the typed event rather than its source. This is what makes the no-new-string-markers rule enforceable instead of aspirational. -
IRC keeps what it is actually good at: the readable channel, multi-client access (The Lounge, gamja, relay-client), the conversation itself, presence, and clarification turns. The channel becomes a projection the controller maintains, and reconstruction-from-
LISTbecomes a repair path rather than the only truth.
What this unblocks downstream — why it is cheaper here than later:
- Phase 2’s API stops being strange. As designed it translates HTTP into
!commandstyped at a chat server by a privileged proxy, solely to keep “the controller is the sole IRC writer” true. Against a store it reads and writes the same rows the controller does. The RBAC-free separate-process posture is unchanged — that property comes from the deployment, not from the IRC round-trip. - Attribution stops being a parsing hack. The trusted
[as <nick>]prefix — this plan’s own listed risk, which Phase 2 is gated on — becomes a column rather than a prefix scraped off a chat line. askstops being an anomaly. This plan already concedes anask“maps to nothing” in IRC. A record with no channel projection is unremarkable once the record is not the channel. Without this, Phase 3 reintroduces two record models — precisely the duplication this plan exists to remove.- Capacity becomes answerable.
MaxActiveTotalbecomes a query rather than a scan of reconstructed state, and finding 1’s cap stops being reachable at all.
Honest cost. This retires “the channel is canonical” (plan-irc-ai-agents,
“Data model”) — that plan’s founding claim and its most elegant property. It is a
larger posture change than the API’s, and belongs in the same paragraph as it. The
mitigating fact: this plan already breaks the weaker form of that invariant, and Phases
3–5 break it further. This phase makes the break deliberate and bounded rather than
incidental.
Order within the phase — revised 2026-08-23 on the “no IRC users” finding.
The original order was (1) measure the cap, (2) dual-write with IRC authoritative, (3) flip reads after a drift-evidence period, (4) typed worker ingest. Steps 2 and 3 are now merged: the evidence period existed to prove the store agreed with IRC before trusting it, and with zero tasks there is nothing to compare. A week of drift logs against an empty system would prove nothing.
Measure the cap.Done — see below. Zero task channels.Store authoritative from the start.Built 2026-08-23 (5d636016). The mirroring is already written and committed (internal/store+ the four choke points); what went is the waiting. Arepairsubcommand (reconstruct the store fromLIST) was considered and dropped 2026-08-24 — there is nothing in IRC worth reseeding from (the “no users” finding), and rebuilding the primary from a lossy replica perpetuates the two-records model this plan exists to remove. The rollback is the flag itself (AGENT_STORE_AUTHORITATIVE=falseresumes mirror mode and reconstructs from IRC automatically); real store recovery is the.dbrestic backup. See the 2026-08-24 decision-log entry.Typed worker ingestBuilt 2026-08-23 — promoted, because it is the prerequisite for Phase 1.5. While a worker’s only route to the controller is posting into its channel, IRC cannot leave the controller without severing the worker path.
The drift check keeps its value even so. It stops being a gate and becomes a regression alarm: once IRC is a projection, it reports the projection falling out of step with the record — which is exactly the failure a projection can have.
Step 1 ran 2026-08-23 — the answer is “correct, not urgent.” A read-only
LISTover the gateway websocket (wss://ws.irc.rrchnm.internal, anonymous, no JOIN or write) returns 5 registered channels: 3 repo hubs (#popquiz,#rrroster,#systems) and 2 standing (#tasks,#firehose). Zero task channels. So the 15-cap is roughly 10 task channels away, not already breached — finding 1 is a latent cliff with no error path, not a live outage. Build this phase deliberately rather than as an incident, and note the two small independent fixes in the design doc that bound the cliff on their own (!archiveshouldChanServ DROP;cmdNewshould read ChanServ’s NOTICE).Caveat: that counts what
LISTreturns, and every channel seen had ≥2 occupants, so nothing provedLISTsurfaces registered-but-empty channels. If it doesn’t, the count understates — and that is its own finding, since reconstruction reads the sameLIST. Onekubectl execsettles it; the read-onlyagent-viewcontext used here has nopods/exec,pods/portforward, or Secret read.Design written 2026-08-23:
vibes/irc/agent-controller/docs/state-store.md— full schema (tasks,repos,task_status_history,events), the METADATA→column map, the four-step build order, deployment constraints, and open decisions. Nothing implemented yet; that doc is what to build against.
Status: all three steps built 2026-08-23 — deployed + verified live 2026-08-24
Step 1 — dual-write.
internal/store(SQLite viamodernc.org/sqlite, the pure-Go driver, because the image buildsCGO_ENABLED=0), wired at the four mutation choke points, plusDriftCheckon the sweeper tick. The controller previously had no PVC —readOnlyRootFilesystem: true, “writes nothing” — so this phase creates one;AGENT_DB_PATHis opt-in and a store that won’t open is logged and skipped, never fatal.Step 2 — the flip (
5d636016, 7 files, 9 new tests). Decisions worth keeping:
- The flip is its own flag,
AGENT_STORE_AUTHORITATIVE, deliberately not a consequence ofAGENT_DB_PATH. Flipping against an empty store reads as every task disappearing at once, and the controller would archive-by-omission.- The write path is unchanged.
metaSetandsetTopicstill emit the same IRC commands, so The Lounge, gamja and relay-client see exactly what they saw before. What moved is where the controller believes state comes from.- Reconstruction from
LIST/METADATA now writes into a separateircView, so the drift check has a real left-hand side instead of comparing the store to itself. The check inverts with the flag and the log line names the direction (“store mirrors IRC” vs “IRC projects the store”).- A message in an unknown task channel no longer invents a task — under the old model a stray PRIVMSG would write a phantom into the record.
- An unreadable store under the flag is fatal, for the same reason the flag is separate.
Step 3 — typed worker ingest.
POST /v1/tasks/{id}/eventswith a per-task bearer token (crypto/rand, 32 bytes hex; nevernanoID, whose timestamp fallback is wrong for a credential), compared withsubtle.ConstantTimeCompare. Tokens are persisted (schema migration 2) because a token held only in memory would be forgotten on reconnect and every later event from a running worker would 401. The wrapper side is inbase/agent-wrapper/src/irc-task.ts: a fire-and-forgetingest()closure, active only when bothCONTROLLER_INGEST_URLandCONTROLLER_INGEST_TOKENare set, so a worker with no ingest configured behaves exactly as before. Ingest requires the store and refuses to start without it, saying why.This retires finding 3 for the events that matter: worker state no longer round-trips through regex-scraped chat prose. The firehose scraper stays for now — it also carries tool telemetry the ingest path doesn’t yet cover.
Verified on
moby@10.112.113.191(eren):go vetclean, all five packages green (bot,ingest,reaper,spawn,store). The cross-language contract was tested with payloads generated by actually runningJSON.stringifyon the wrapper side rather than hand-written fixtures.Gate before flipping
AGENT_STORE_AUTHORITATIVE(revised 2026-08-24): originally arepairsubcommand that backfills the store fromLIST; dropped — the rollback is the flag (off → mirror mode reconstructs from IRC), and real recovery is the.dbrestic backup, so the gate is now “the.dbis inrestic-backup-vibesand a restore has been exercised at least once,” not a repair path. Do not go authoritative with neither.
Verify: kill the controller mid-transition and confirm no task ends up with a topic
and a status that disagree; archive 20 tasks and file a 21st (today’s ceiling is 15);
run a task whose tool calls carry 300-character commands and confirm every event reaches
dashboard-backend intact; confirm a task list survives a full history-expiry window.
Phase 1 — per-task worker shape and credentials
- Per-task pod template selection, mechanically like
!model(METADATA → spawn). Required beforeweb-developercan migrate: it needsgh/git, andaccessibility-testerwould need Chromium + Pa11y. - Per-task secret selection. Today one shared
AgentSecretName;web-developer’s PAT is fine-grained and scoped to exactly the repos inweb-developer-channel-repos.yaml. Broadening it across every task is a security regression and is not acceptable.
Verify: two tasks in the same controller spawn different pod shapes with different scoped secrets.
Phase 1.5 — IRC becomes a client (new, 2026-08-23)
Status — chunks 1+2 built + (mostly) verified live 2026-08-25. The extraction
is being done in three reviewable chunks, ordered so the first two land in mirror
mode (no backup/flip needed) and only the last rides the
AGENT_STORE_AUTHORITATIVE flip. Both seams a client needs — drive + observe —
now exist and are proven in production; the controller is client-agnostic.
- ✅ Typed command API (inbound seam) —
9e753812+ deploy5d623867, VERIFIED LIVE 2026-08-25.internal/apiHTTP server +internal/bot/api.gotransport-neutralAPI*methods that reuse the existing domain ops (createTask, cmdReady, …) — one state machine, two front doors.AGENT_API_ADDR/AGENT_API_TOKEN, off by default (token seeded to OpenBaokv/eso/agent-controller). Phase 1 was closed THROUGH this API, sidestepping the broken IRC hub-join entirely:POST /v1/taskswith a per-task pod/secret →/ready→ the worker pod showed mem-limit 3Gi, PVC 7Gi, and creds bound from the per-task secret. No ergo hub involved. - ✅ Event stream (outbound seam) — built, moby-green.
- 2a
GET /v1/events?since=&limit=cursor read (a8b0e5a8), VERIFIED LIVE: returned the full faithful lifecycle of the API-driven task (task_created→…→worker_exited). Reads the store’s event log directly (no bot lock). - 2b
GET /v1/events?stream=1live SSE tail (a60704cb): an in-processinternal/events.Broadcasteremit() publishes to; subscribe-then-replay- then-live with id-dedup so no gap/dup at the seam;SaveEventnow returns the durable id the tail keys on. Awaiting merge+deploy to verify live.#firehoseis untouched — it stays a fan-out; the api stream is a peer.
- 2a
- ◻ Extract
clients/irc. Move ircconn + the 3 command switches + projections + hub-join + reconnect-rebuild into a separate binary; the controller drops itsIRCfield and ergo import. Requires the store to be authoritative (the controller can no longer rebuild from IRC), so this is the one chunk gated on the backup/flip.
Discovered en route (both real, both fixed/queued): the IRC hub-join is broken
— the controller sends JOIN #<repo> on reconnect but ergo does not seat it
(#firehose register also fails “must be an oper” every boot), so !repo in a
hub draws no reply; and cmdBindRepo copied repo/url/branch but not
pod/secret, so a repo-less-draft-then-bound task silently used controller defaults
(fixed in aab06bf2). The command API sidesteps the hub-join bug entirely, which
is exactly the point of decoupling — Phase 1 was verified over it while the hub
stayed broken. The hub-join/ergo-bootstrap issue is still open, but no longer
blocks anything: it only matters for driving tasks from an IRC hub, and Chunk 3
will move that whole surface into the clients/irc adapter anyway.
Chunk 3 — extraction plan (scoped 2026-08-25; execute when the flip lands)
Split 3a/3b/3c (buildable NOW, mirror mode) + 3d (the cutover, flip-gated). Building 3a–3c ahead means the flip triggers only a focused, reversible cutover.
Target. Controller = a headless HTTP service: state machine, store
(authoritative), scheduler, k8s spawner, command api (/v1/tasks), event stream
(/v1/events), worker ingest. No ergo, no ! parsing, no b.irc.*.
clients/irc = a separate binary: the ergo connection + !command→api
translation + event-stream→IRC rendering + hub-join/reconnect. ergo goes optional.
3a — complete the command api (additive, deploy now). The adapter can only
translate a command the api serves; Chunk 1 covers create/ready/kill/complete/
reopen/model/desc/get/list. Add: POST /v1/hubs (!hub), GET /v1/hubs,
GET+POST /v1/hubs/{name} (list/get/edit url·branch·pod·secret — the !repo
hub form); POST /v1/tasks/{id}/repo (bind a draft — !repo <name>), /archive,
/permission (!a/!d); GET /v1/tasks?status=&repo=&mine= (!tasks filters).
Each = a bot API* method reusing the existing op + route + test. !help/!status
need no api.
3b — event→IRC renderer (a library in clients/irc). A materialized view:
bootstrap a task/hub cache from GET /v1/tasks + /v1/hubs, keep it current from
/v1/events, render each event to IRC — the projection the domain ops do inline
today, moved out. Table:
| event | IRC render |
|---|---|
| task_created | register #chan, join, topic [pending] title, metaSet(status,requester,description,created,repo,url,branch,pod,secret), Privmsg desc, invite requester, Notice hub+#tasks+requester |
| status | topic [<status>] title, metaSet status; blocked/for_review/done → DM requester |
| task_ready | Privmsg “ready” |
| worker_assigned / worker_spawned | metaSet(assignee,spawned,job), Notice #tasks |
| worker_online / worker_exited | Notice / (status carries the rest) |
| task_updated | metaSet changed fields |
| task_completed | Privmsg “complete” |
| task_archived | topic/metaSet archived |
| hub_created / hub_updated | register hub, topic [repo] name, metaSet(repo,url,branch,pod,secret) |
| permission_request/resolved, tool_call_* | #firehose only |
| channel_registration_failed | Notice #tasks + requester |
| (every event) | republish as the #firehose JSON line |
Renderability: enrich task_created to carry description·repo·url·branch·pod·secret
(the heavy render); elsewhere the adapter uses its cache or a GET /v1/tasks/{id}
fallback. Lean events + fetch-on-demand.
3c — the clients/irc binary (build+test now; deploy only at cutover — it
can’t share IRC with the controller’s own connection, so it stays scaled-0 until
3d). Structure: the reconnect loop (moved from controller main) + internal/ircconn
(promote to shared or copy); the command parser (the 3 HandlePrivmsg switches) →
HTTP calls to the api, sender nick → actor; an SSE subscriber on
/v1/events?stream=1 → the 3b renderer; the lifecycle hooks
(OnConnect/OnListEnd/HandleListEntry/HandleNotice) + hub-join/registration —
fix the hub-join bug here (now the adapter’s concern); config = ergo+SASL
(moved) + api url/token + event url.
3d — the cutover (flip-gated). In order: (1) flip AGENT_STORE_AUTHORITATIVE=true
(the gate — needs backup+restore first; the controller now reads state from the
store, not IRC LIST+METADATA); (2) strip IRC from the controller — drop the IRC
field, HandlePrivmsg+switches, all b.irc.* + projection helpers,
OnConnect/OnListEnd/HandleListEntry/HandleNotice/setupFirehose, ircconn + the
reconnect loop; the domain ops shed their b.irc.* calls (mutate + emit only);
(3) deploy clients/irc (new Deployment, scale from 0) + a CNP allowing
clients/irc → agent-controller:9101 (mirror agent-controller-ingest for :9100 —
mind the Ansible-vs-Argo apply path for netpol.yaml); (4) point the adapter’s ergo
account (reuse controller or a new nick; ergo-bootstrap adjusts).
Commit sequence: (1) 3a api-expansion [deploy now]; (2) 3b+3c clients/irc built [not deployed]; (3) 3d flip + strip + adapter-deploy + netpol [at flip].
Risks. Cutover is big-bang for the IRC path (controller-IRC-removal +
adapter-deploy land together; if the adapter fails, IRC is dark) → mitigated by
verifying api+adapter before the flip and keeping the pre-3d controller image
pinnable for a flip-back rollback. Event renderability gaps → GET /v1/tasks/{id}
fallback. Two IRC clients can’t coexist → 3c stays scaled-0 until 3d.
Status: SHIPPED — go-live cutover completed 2026-08-26; controller headless, clients-irc adapter live (1/1, merged to main)
The flip (
AGENT_STORE_AUTHORITATIVE=true) landed + verified 2026-08-24; the IRC-ectomy cutover (the runbook below) executed 2026-08-26 — the controller now runs the headless image (sha256:48c15a01…) andclients-irc(replicas 1) carries the IRC projection, stable since. The extraction (was code-complete oncc-quiet-yarrow, now onmain):
- 3a command api + channel-free
/v1/repos(internal/api,internal/bot/api.go).- 3b/3c the
clients/ircadapter asinternal/adapter(client + renderer + parser + loop), run via theagent-controller ircsubcommand.- 3d the irc-ectomy (
4a836fc0): controller runs HEADLESS — one bot on anullIRC, no ergo,select{}; guarded by the flip. Deploy manifests (b1012bc7):clients-ircDeployment (replicas: 0),agent-controller-apiService :9101,agent-controller-ingestCNP +:9101 from clients-irc. Both built green on moby (vet+test+build).Go-live runbook (deliberate, gated on the headless image):
- Merge
cc-quiet-yarrow→main. Argo syncs the Service + the dormant (replicas 0)clients-irc; the controller still runs the OLD image, still IRC-connected — nothing changes yet.- Apply the netpol:
make k0s-vibes(netpol.yaml is Ansible-applied, not Argo).- Let CI build the merged commit + the
pinjob update the agent-controller digest. Argo rolls the controller to the headless image (Recreate) — it drops its ergo connection. IRC projection is dark from here until step 4 (tasks, workers and the command api are unaffected — only the IRC face is absent).- Flip
clients-irctoreplicas: 1(one-line commit → Argo, or a stop-gapkubectl scalethat a commit then makes durable). The adapter logs into ergo ascontroller, bootstraps its cache from the api, tails/v1/eventsand reconnects the IRC projection. Verify#tasks+ a!command round-trip.Rollback: fast path — re-pin the kustomization
agent-controllerimage to the pre-cutover digest (sha256:8dd38ec9a64c…, the in-process-IRC image current as of this commit); Argo rolls the controller straight back to speaking IRC, no CI wait. Durable path — revert4a836fc0(rebuild → re-pin). Either way setclients-ircback to replicas 0. The store-authoritative flip is independent and STAYS — the reverted controller still reads state from the store. The Service/CNP/dormant Deployment are inert with the adapter at 0, so they can stay across a rollback.
The gap this closes. “Proposed target state” names three layers — Clients /
Dispatcher / Harness — but the dispatcher’s own row says it owns IRC writes. The
controller is an IRC client. The Lounge, gamja and relay-client are end-user
clients; the IRC adapter — the thing turning !new into a domain command — is fused
into the controller. internal/bot/bot.go is 1,561 lines carrying 76 b.irc.* call
sites and 18 !command cases alongside the scheduler and state machine.
Why it is worth its own phase rather than cleanup. Phase 2 exists because of this
coupling. Its design — HTTP → !commands → chat server → controller — is what you must
build when the controller has no typed inbound surface. Decoupling and Phase 2 are
competing answers to the same question: how do non-IRC clients drive the controller?
The original plan chose the one that preserves IRC’s privilege. With no IRC users, that
choice is hard to defend, so this must be settled before Phase 2 is built, not after.
The change. Extract the IRC client into its own process — clients/irc — speaking
the same typed interface as pi-mom and relay-client. The controller keeps the store,
scheduler, k8s spawner and typed API, and loses its ergo connection entirely.
IRC IS NOT BEING REMOVED. It stays a supported client option, and the adapter keeps
the full command surface: all 18 ! commands, the topic/METADATA projection, the
readiness checklists, worker nick rendering, #firehose publication. relay-client, The
Lounge and gamja keep working exactly as they do — someone built a purpose-made IRC PWA
for this and that investment survives. What changes is that IRC stops being the
controller’s inbound API and becomes one client among several.
What falls out:
- The reconnect-rebuild constraint dissolves.
bot.New(cfg, conn, spawner)is rebuilt on every reconnect, which is why “Facts that constrain the design” lists anything long-lived must not hold a*Bot. That rule exists only because the domain object is welded to the connection. - The channel ceilings stop being the scheduler’s problem.
max-channels-per-client,max-channels-per-accountand the fakelag JOIN storm become adapter concerns. - ergo becomes genuinely optional — an IRC-less deployment becomes expressible, which the current design cannot say at all.
- The invariant gains its sibling: nothing that owns state talks to an LLM, and nothing that owns state speaks a chat protocol.
Prerequisites — both now met in code (2026-08-23), neither yet deployed:
- ✅ Phase 0.5 (store authoritative) — otherwise the adapter cannot be stateless,
because IRC still is the state. Built behind
AGENT_STORE_AUTHORITATIVE. - ✅ Phase 0.5 step 3 (typed worker ingest) — workers previously reached the
controller only by posting
[status]/[meta]into their channel. While that held, removing IRC from the controller would sever the worker path. Built, controller and wrapper both.
The honest qualifier: met in code is not met in production. Nothing is deployed, so the adapter extraction would be cutting a seam that has never run. Deploying Phase 0 and 0.5 — and getting one scratch task through the ingest path end to end — is the thing that turns these from written to true.
After both, IRC’s only remaining roles in the controller are reading commands, rendering projections, and fanning out the firehose — all adapter concerns, which is exactly when the extraction becomes mechanical.
Honest cost. It is the largest single refactor in this plan: 1,561 lines with
command parsing, domain logic and IRC rendering interleaved. The saving grace is that
the domain logic is already well covered by tests that use a fakeIRC, so the seam
being cut is one the tests already exercise.
Verify: every ! command works identically from The Lounge with the controller
holding no ergo connection; a task filed from the adapter and one filed from the typed
API are indistinguishable in the store; killing the adapter leaves scheduling and
worker supervision running.
Phase 2 — the API process and attribution
Reframed 2026-08-23 — do not build this as originally written. Everything below describes a proxy: a separate process holding an ergo write identity that translates typed HTTP into the
!commandsthe controller already validates. That shape exists solely because the controller’s inbound surface is IRC, and it was chosen to avoid putting an inbound listener on the k8s-privileged process.After Phase 1.5 neither reason survives. The controller has a typed inbound surface, and the separate-process/no-RBAC posture comes from the deployment — a second binary with its own Deployment and no cluster role — not from the IRC round-trip. So the API stops being a translator and becomes the controller’s own interface, with the IRC adapter as a peer client rather than the substrate beneath it.
What survives from below unchanged: the endpoint surface, the RBAC-free separate Deployment, the cluster-internal Service with no Gateway route, and — most importantly — attribution. The
[as <nick>]problem is real regardless of transport, and it is still what this phase must not ship without. What goes is the IRC translation layer and the “sole IRC writer” justification for it.
agent-controller apisubcommand; separate Deployment, no RBAC, ergo write identity, bearer token from ESO, cluster-internal Service only.- Attribution: the trusted
[as <nick>]prefix fromplan-agent-task-console.md, sobot.go:630records the real human rather than the proxy. Solve once, for both clients. - The handler must not hold a
*Bot— that instance is rebuilt on every reconnect. Commands go over IRC like any other client’s.
Verify: POST /v1/tasks from curl creates a draft with the correct requester;
kill the controller mid-flight and confirm state rebuilds from IRC and the API keeps
working.
Phase 3 — the ask path, and the registry split
- Split
vibes-slack.tomlper “Splitting the config” above:[[agent]]blocks become a shared capability registry (id, endpoint, pod template, secret, default provider+model,shapes); the ACL andMOM_*toggles stay Slack-side. This is the first genuinely shared config, so it is where the shared directory gets created. POST /v1/askresolves the capability from the registry, routes to the warm Deployment, returns the response, emitsask_started/ask_completedto#firehose.- No channel, no PVC, no slot.
- Reconcile repo binding:
web-developer-channel-repos.yamlbecomes a Slack-side default feeding the one repo-binding model, not a second source of truth.
Verify: an ask returns in seconds and appears on the firehose without creating a
task channel; an IRC-only deployment can read the registry with no Slack config present.
Status: built 2026-08-26 (
342506ee) — controller-side, undeployedThe registry + ask path landed on
cc-quiet-yarrow:
internal/capability— the Capability type + Validate + YAML loader; store migration #6 (capabilities table + CRUD + ResolveCapability);/v1/capabilitiesCRUD; declarativecapabilities.yaml(the 4 warm agents, all ask-only) folded into theagent-controller-configConfigMap (AGENT_CAPABILITIES_PATH).internal/ask— the Asker: resolve capability → emit ask_started → proxy to<endpoint>/promptoff-lock → emit ask_completed.POST /v1/ask{capability, prompt, actor, session} → {response, ok, error, duration_ms};sessionmaps to the agent’s channel_id for conversation continuity. Agent error ⇒ 200 ok:false. New event kinds ask_started/ask_completed flow through the same store + stream as tasks; the IRC adapter republishes them to #firehose.- bot.Emit — the public event hook the ask path uses (brief locked calls bracketing the slow round-trip);
AGENT_ASK_TIMEOUT_SECONDS(default 300).The routing here duplicates vibes-slack.toml’s [[agent]] endpoints until Phase 4 points pi-mom’s forward_* tools at
/v1/ask— expected transitional overlap; keep the two in sync meanwhile. Merged tomain+ deployed in the headless controller (AGENT_CAPABILITIES_PATH+AGENT_API_ADDR=:9101wired, the 4 warm ask-capabilities reconciled fromcapabilities.yaml). VERIFIED LIVE 2026-08-29:POST /v1/ask {capability:general}returned"pong"ok:truein 992 ms, and created no task channel / worker pod / PVC — theaskpath routes end to end and stays off the task machinery. Registry lives in the controller ConfigMap (no Slack config needed), so the “IRC-only reads the registry” criterion holds structurally. Phase 3 closed. The transitional duplication ofvibes-slack.toml’s[[agent]]blocks dissolves when Phase 4 points pi-mom’sforward_*at/v1/ask.
Phase 4 — pi-mom becomes a translator
forward_*tools call the API instead of spawningworker.py; deleteworker.pyand the detached-spawn path frompatch-tools.js.- Keep the ACL check client-side, keep channel-tail injection, keep the closed-list typed tools.
- pi-mom holds a durable task →
(channel, thread_ts)map so results andnotifyRequesterDMs reach the originating Slack thread. This is the ledger it has never had.
Verify: a Slack request produces a firehose event, and its result lands back in the right thread across a pi-mom restart.
Phase 5 — migrate the work that wants a lifecycle
web-developer and popquiz move to task dispatch. accessibility-tester gains
task alongside the ask path it already has — a site audit files a task, a
single-page check stays conversational; this is the first capability to exercise both
shapes and therefore the real test of the per-request model. general stays ask-only.
Run both dispatch paths in parallel for one release before removing the direct-POST path.
Verify: a Slack-filed web-developer request appears as #<repo>-task-N, is
watchable and steerable from relay-client by an IRC user, and opens the same PR it does
today. Separately, the same accessibility-tester capability answers a spot-check in
seconds and files an audit task — without duplicated registry entries.
Phase N — reconcile the docs
plan-irc-ai-agents (IRC is no longer the only control surface, and after Phase 0.5 its
§“Data model” rule — “the channel is canonical… no separate state store” — is retired;
its §“Divergences” is the right place to record that),
plan-agent-task-console.md (its console-backend is this API process, and its
read-model section predates the store),
slack/AGENTS.md (pi-mom’s role changed), and plan-agent-workspace-renderer.md
(its Phase 4 largely evaporates).
Risks
-
An inbound surface adjacent to the only k8s-privileged component in the namespace. Mitigation: the API is a separate process with no RBAC, no Gateway route, cluster-internal Service, bearer token. It can create tasks; it cannot create pods. Worst case is task spam, not cluster compromise.
vibes-isolationalready governs same-namespace traffic. -
The controller gains durable state — and a PVC — it never had. Phase 0.5 adds a SQLite file on a new PVC, so the process stops being purely reconstructible and starts having a backup/restore story, a schema-migration story, and (on
local-path) a node affinity. Mitigation (revised 2026-08-24): two recovery paths, neither the droppedrepairsubcommand. Cheap rollback if the authoritative flip misbehaves:AGENT_STORE_AUTHORITATIVE=false→ mirror mode reconstructs from IRC on boot (the write path to IRC is unchanged, so IRC stays current) — recovers IRC-visible tasks but not store-only data (askrecords, aged-out archives). Real DR: the.dbfile folded into the existingrestic-backup-vibessweep alongsideergo-data, with a restore actually tested before going authoritative. The alternative — leaving the record in a chat server that silently caps, expires, and truncates it — is the larger durability risk, not the smaller one. -
Dual-write drift during Phase 0.5. Step 2 writes both the store and IRC, so the two can disagree before reads flip over. Mitigation: IRC stays authoritative through step 2; the store is compared against reconstruction, not trusted, until step 3.
-
Attribution regression. If
[as <nick>]is not landed, every Slack-filed task readsrequester = <proxy>and!tasks minebecomes useless. Mitigation: Phase 2 gates on it; do not ship the API without it. -
MaxActiveTotalis now shared. Default 3. Slack task volume competes with IRC task volume for the same three slots. Mitigation: size it before Phase 5;askwork deliberately does not consume slots. -
Phase 0 touches a live path. The provider unpin changes spawn env for every existing task. Mitigation: default to
anthropicwhen unspecified so current behaviour is byte-identical until someone opts in. -
Our pi-mono pin is a scope rename and ~11 minors behind. We pin
@mariozechner/pi-ai/pi-agent-coreat 0.73.1 (base/agent-wrapper) and@mariozechner/pi-momat 0.70.6 (slack/bot/Dockerfile). Upstream is@earendil-works/pi-*at 0.84.2 — a package-scope migration on top of the version gap, so this is a rename-and-bump, not a bump.irc/pi-mono-update-plan.mddocuments the 0.54 → 0.73 bump and is the template for doing it again; the same blast radius applies (agent-wrapper is a base-chain image under six agents, the IRC worker, and slack/bot). Mitigation: treat it as its own change, before or after this plan but never entangled with it. Nothing in Phase 0 requires the bump — it is Go-side only, and the providers we need predate our pin. The bump is only load-bearing for the lanes-based threading fix, which is deferred anyway.Sized 2026-08-22 — smaller than the version gap suggests. Everything the wrapper actually calls survives to 0.84.2:
beforeToolCall,toolExecution(still defaulting to"parallel", so the"sequential"override stays necessary), and all fiveAgentmethods in use —subscribe,prompt,waitForIdle,abort,steer. Most breaking changes between 0.73 and 0.84 land onAgentHarness/SessionStorage, which we do not use. Only three items actually apply:Version Change Impact 0.74.0 package scope → @earendil-works/*mechanical rename across package.json+ imports0.75.0 minimum Node.js 22.19.0 base-image requirement — check rrchnm/vibes/base0.81.0 Agent’s optionalstreamFnfallback replaced by a requiredstreamFunctionthe one real code change Also re-test the esbuild bundle: 0.80.0 removed the
/baseselective-provider entrypoint and 0.81.0 changed how built-in providers enter bundles, which is the same area as the@opentelemetry/apibuild gotcha documented inirc/pi-mono-update-plan.md. -
The review gate spans two systems.
for_reviewis accepted by!completein IRC; a Slack user filed the work. Mitigation: Block Kit buttons in the originating thread, callingPOST /v1/tasks/:id/actions.notifyRequester(bot.go:376) is the trigger, and turnstone proves the interaction works over Socket Mode. Risk downgraded from “unsettled UX” to “build it, copying a known-good pattern.” -
Slack threads have no IRC equivalent. A
taskmaps to#task-Ncleanly; anaskthread maps to nothing, and follow-ups must reach the same warm session. The wrapper keys conversation state bychannel_idalone and already collides across concurrent threads — moving dispatch relocates this rather than fixing it. Tactical mitigation: adopt turnstone’s route-key shape —(channel, user_id?, thread_ts?)round-tripped to an opaque id and persisted, so routes survive a restart. Fix it in the same change that moves dispatch; do not carry the bug forward. Principled fix, upstream:pi-agent-corenow models exactly this. Its lanes are named cursors into an immutable entry tree, each owning its own leaf, model config, queues, and at most one operation — and the documented worked example is literally a Slack thread (harness.createLane("slack:1719432.0021", at: <leaf>),packages/agent/docs/harness.md§0.4). A lane per thread over one shared channel history gives mutual exclusion by construction, plus crash recovery via the durableop.stateregister. Verified 2026-08-22: lanes do not exist at our pin. Harness v2 (implement harness v2,promote durable harness API) is contained only in v0.84.0 and later; we are on v0.73.1. It is also very new — 0.84.2 is HEAD, andpi-server/pi-protocolalongside it are marked experimental. Take the tactical route-key fix now; revisit lanes only after the version bump and after that surface has settled. -
Button state lost on rollout. Approval buttons whose payload carries the correlation data break on every pi-mom restart. Mitigation: static
custom_ids with correlation stored out-of-band, perturnstone/channels/slack/bot.py. -
pi-mom appears orphaned upstream — the most important finding here. Verified 2026-08-22:
@mariozechner/pi-momhas no source in either upstream repo. It is absent from thepimonorepo, andpi-chat— which the monorepo README names as the home of “Slack/chat automation” — supports Telegram and Discord only (ChatService = "telegram" | "discord"; no occurrence of “slack” anywhere in the tree). The vendor’s chat story has moved to a pi extension with per-channel Gondolin micro-VMs, and Slack did not come along. Consequences: no visible upstream to contribute a patch to, no upgrade path for the0.70.6pin, and a fifthpatch-*.jswould be a new anchor into the compileddist/of an unmaintained package — on top of four existing patches that already carry their own maintenance doc and version-pin ritual.Decision: keep pi-mom as-is and proceed. A PR adding a Slack adapter to pi-chat is in flight, so the ecosystem is not abandoning Slack — it is relocating, and there will be a migration target when we want one. Until then pi-mom is ours to change, which is the flexibility it was chosen for. Mitigation: keep the patch count minimal, keep
docs/pi-mom-patches.mdcurrent, and re-evaluate when pi-chat’s Slack adapter lands (below). -
Migration target when pi-chat’s Slack adapter lands: pi-chat is Apache-2.0, TypeScript, pi-mono-native, with two small well-defined seams —
DiscoveryProvider(validate/fetchSnapshot) andLiveConnection(send,sendImmediate, typing,syncPreview, reply-to, resume cursor). Two things to check when evaluating it, not before:LiveConnectionas it stands has no approval or interactivity primitives, so approve/deny may still be ours to build; and pi-chat wants QEMU plus a Gondolin guest image andtmuxper connection, which is a serious constraint under k0s (nested virt,/dev/kvm) — thoughplan-k0s-kata-runtimeclass.mdsuggests that ground has been surveyed. Direction, not a plan.
Rollback
Phases 0–3 are additive; nothing depends on them until Phase 4. Phase 4 is a revert of
patch-tools.js plus restoring worker.py. Phase 5 runs both paths in parallel for a
release, so rollback is flipping the capability back to direct POST.
Phase 0.5 rolls back per step: steps 1–2 are observation and dual-write, so reverting
is dropping the store. After step 3 — and after the authoritative flip — the rollback is
simply AGENT_STORE_AUTHORITATIVE=false: the controller resumes mirror mode and
reconstructs from IRC on boot automatically (no repair subcommand needed — that was
dropped 2026-08-24), returning to today’s behaviour with today’s ceilings. It recovers
only IRC-visible tasks; store-only data (ask records, aged-out archives) comes back
solely from the .db restic backup, which is why a tested restore gates the flip.
Open questions
Where does a Slack user acceptResolved 2026-08-22: in the originating Slack thread, via Block Kit buttons over Socket Mode. See “Slack can carry the full workflow”.for_reviewwork?Should turnstone’s channel adapter be the Slack front-end?Resolved 2026-08-22: no. pi-mom stays; turnstone is reference only and is not modified by this plan.- Does pi-mom’s Socket Mode client handle
block_actionspayloads, or onlymessageevents? The entire review-gate-in-Slack design depends on it, and it is still the one open unknown that could reshape Phase 4. Source is unavailable — pi-mom is in neither upstream repo (see the orphaning risk) — so the only way to answer it is to read the shipped package in the running image:kubectl -n vibes exec deploy/slack-bot-pi-mom -c pi-mom -- grep -rl "block_actions\|interactiv" /usr/local/lib/node_modules/@mariozechner/pi-mom/dist/. Do this before any of Phase 4 is designed. If the answer is no, the options are a fifth patch (no upstream to contribute to) or bringing a Socket Mode client of our own alongside —@slack/boltsupports interactivity over Socket Mode and would not need a public URL. Does the harness support non-Anthropic providers?Resolved 2026-08-22: yes, comprehensively. See Phase 0.- Should the wrapper move to
pi-server/pi-protocol/pi-client? Upstream has grown a session server since our pin: a CBOR wire protocol, durableSessionMetadatareadable without acquiring a runtime,SessionSnapshotfor runtime state, exclusive/shared session leases (PiSessionOwnershipErroron contention), and a SQLite session backend. That is a principled fix for two things this plan currently works around — the in-memory per-channel state that dies on restart, and the concurrent-thread collision — and it modelsask’s warm-session-reattach case directly (listSessions/openSession/attachSession). It does not replaceagent-controller: it is a session protocol, not a scheduler, state machine, or pod spawner. The composition would be controller-schedules-and-spawns, pi-server-owns-the-session-inside, replacing the wrapper’s bespoke/promptendpoint and itsMap<string, Agent>. Blocker:pi-serverandpi-protocolare marked “Experimental… may change or be removed without notice.” Do not build on them yet; re-evaluate at the version bump. Where does the capability registry live?Resolved 2026-08-22: split out ofvibes-slack.tomlinto a shared registry in Phase 3; Slack keeps its ACL and toggles. See “Splitting the config”.ShouldResolved 2026-08-22: the question was malformed — work shape is per-request, not per-agent. It supports both.accessibility-testerbecome atask?- Does
dashboard-backendmerge into the API process? Both are Go, both hold an ergo connection, one reads and one writes.plan-agent-task-console.md§“Security” leaves the read/write split open for the same reason. Phase 0.5 changes the calculus: with a store, the read model can be a query against it instead of a second firehose subscriber holding its own in-memory reconstruction, which is most of whatdashboard-backendcurrently is. Decide after 0.5, not before. - Is the store SQLite or Postgres? Phase 0.5 assumes SQLite on the controller’s
existing PVC — no new workload, no new backup story, and the controller is
single-writer by design so the concurrency limits do not bite. Postgres only becomes
interesting if the API process or
dashboard-backendshould read the store directly rather than through the controller, which is the open question above.
Decision log
2026-08-26 — unbundle repo into a run-profile + repo hints (additive/sugar)
Arose from a design conversation after the cutover went live: “repo” conflated four things and only one of them is a real controller concern.
The two dispatch shapes, stated plainly (context for the rest): an ask routes
to a WARM, standing agent Deployment (a vibes-agent-wrapper on :9000) — synchronous,
no pod spawn, ephemeral in-memory state; a task spawns an ON-DEMAND worker pod (a
K8s Job + per-task PVC) with a full lifecycle. Only tasks clone repos, so “multi-repo”
is entirely a task concern. A capability is the named preset for an ask; a repo is the
named preset for a task — same idea, projected differently by each client.
The problem. A repo{name, url, branch, pod_template, secret_name} welds together:
(1) a pre-clone target {url, branch}, (2) credentials secret_name, (3) pod shape
pod_template, (4) an IRC grouping (#<repo>). The worker is a general agent
(gh+git+egress), so (1)/(3)/(4) never limit what it can touch — it can clone any
repo. The ONLY real constraint on multi-repo work is (2): the pod holds one
secret_name’s tokens, so it can push only where that token reaches. So repo binding is
a convenience + a credential/pod boundary, not “the repo the worker works on” — and
a repo-less task already proves the binding is optional.
The model (decided: additive/sugar — nothing live breaks):
- run-profile
{name, secret_name, pod_template}— the controller-owned execution/ security boundary: what creds the pod mounts + what shape it runs at. A task requires one (or the controller default). This is the real concept the controller should own. - repo hints
task.repos = [{url, branch}, …]— the work’s scope, unbounded, a convenience: pre-cloned into the workspace, agent may clone more. NOT a boundary. repostays as SUGAR:repo popquizexpands to{profile: popquiz, repos: [popquiz's url/branch]}. Live tasks,SEED_REPOS,web-developer-channel-repos.yamland the#<repo>hub channel all keep working;profile+repos[]are added as the primitives for the multi-repo case. Migration is mechanical — today’s repo already IS a profile welded to one hint.
Scope boundary (calibrated). This makes the model correct and single-credential
multi-repo natural (a broad PAT in the profile, repos:[A,B,C], the agent clones+pushes
all). It does NOT by itself solve cross-credential multi-repo push — two orgs, two
tokens needs the worker to route creds per-remote (a git credential-helper feature).
That’s a separate worker-side step; the unbundle just gives it a place to hang
(secret_name → secrets[]) later. v1 is single-secret profiles.
Sub-decisions. (a) sugar, not replace — above. (b) The #<repo> hub channel is
adapter-only: once the controller emits repo/profile events, the adapter derives the
channel from the primary repo hint (or maps to #<profile>) — the controller stops
caring. (c) single-secret profiles for v1; multi-secret deferred with cross-cred push.
Status: modeling decided, NOT built. Sequence when picked up: profiles store +
/v1/profiles (mirrors capabilities/personas) → task.repos[] + profile on
create/spawn (repo-sugar expands to them) → adapter derives the hub channel from the
primary hint. Relates to the pending hub_* → repo_* event rename (the events are
already repo-shaped/channel-free — only the kind name is IRC-flavored) and the
still-open SEED_REPOS-didn’t-persist (“0 repos” after the flip) bug.
2026-08-24 — repair dropped; store rollback is the flag, DR is the backup
Phases 0 and 0.5 are now deployed and verified live (provider unpin proven end to end
on both OpenRouter and Anthropic with real PRs; store mirroring + typed :9100 ingest
running and drift-clean), so the one open question for 0.5 was what gates flipping
AGENT_STORE_AUTHORITATIVE.
The originally-owed repair subcommand — reconstruct the store from IRC’s LIST +
METADATA — is dropped. It was a migration-era crutch: there is nothing in IRC worth
reseeding from (the “no users” finding), IRC’s copy is lossy anyway (1-week history TTL,
and no ask records / transition history / ingest tokens), and rebuilding the primary
from a replica perpetuates the two-records model this plan exists to remove.
What replaces it:
- Rollback = the flag.
AGENT_STORE_AUTHORITATIVE=falseresumes mirror mode, which already reconstructs from IRC on every boot; the write path to IRC is unchanged, so IRC stays current. No subcommand needed. Recovers IRC-visible tasks only. - DR = the
.dbrestic backup. The gate on going authoritative becomes “the.dbis inrestic-backup-vibesAND a restore has been exercised at least once,” not a repair path. Store-only data (askrecords, aged-out archives) comes back solely from the backup.
Do not flip to authoritative with neither a proven backup nor a rollback path — that is
the archive-by-omission cliff (a lost node-local local-path PVC and the controller comes
up believing every task vanished). This session also cleared the deploy blockers that
made the above real rather than theoretical: the vibes/-namespace image-name mismatch
that silently defeated auto-pin, and an uppercase-task-id bug that made every
pvc-task-<id> an invalid K8s name (so no worker had ever actually spawned). Finding 3
was also closed beyond its original scope — the controller no longer scrapes chat; typed
ingest is the sole worker→controller path (hardened with retry/flush, required at boot).
2026-08-23 — Phase 0.5 built end to end; the flip is a flag, not a consequence
All three steps landed. Two choices are worth remembering because both were tempting to get wrong:
The store becoming authoritative is its own config flag, not something AGENT_DB_PATH
implies. Deriving it would mean that deploying the code moves the system of record on
its own — and against a store that has never been populated, the controller would come
up believing every task had vanished and archive by omission. AGENT_STORE_AUTHORITATIVE
off is the complete rollback: the reconstruction path is untouched and resumes owning
state.
Reconstruction still runs when the store is authoritative, but writes somewhere else.
The instinct is to switch it off entirely. Keeping it, pointed at a separate ircView,
is what gives the drift check a real left-hand side — otherwise it compares the store to
itself and always agrees. The check inverts with the flag, and the log line says which
direction it checked, because “they agree” means two different things depending on which
side is the record.
The corollary for Phase 1.5: its two stated prerequisites are now met in code. They are not met in production — nothing is deployed — and cutting the largest refactor in this plan against a seam that has never run would be trading one unverified thing for two.
2026-08-23 — the IRC stack has no users; sequencing reordered, Phase 1.5 added
A LIST against ergo returned zero task channels — and since !archive never
deregisters, any task ever filed would still have one. No pvc-task-* PVCs either.
The IRC agent stack has no users. It is staying as a supported client option, so
nothing here removes it, but the original sequencing was shaped by don’t break the
running IRC system and there is no running IRC system.
Three things follow, each retiring an earlier decision:
The dual-write evidence period goes. Phase 0.5 step 1 existed to prove the store agreed with IRC before reads flipped. With zero tasks there is nothing to compare. Steps 1 and 2 merge; the mirroring code stays, the waiting goes. The drift check survives with a changed job — no longer a gate, now a regression alarm on the projection.
Decoupling IRC from the controller becomes a phase, and an early one. The plan’s own
three-layer diagram claims a client layer, but the controller is an IRC client — 1,561
lines with 76 b.irc.* sites and 18 !command cases fused to the scheduler. That was
never written down as work. It is at its cheapest now: the refactor’s risk was almost
entirely “breaks production,” and there is no production. Every task filed from here
raises the price.
Phase 2 is contingent on it, not settled. The API-as-proxy design — HTTP translated
into !commands typed at a chat server — is what you build when the controller has no
typed inbound surface. Decoupling and Phase 2 are competing answers to the same
question, and the original plan picked the one preserving IRC’s privilege. With no IRC
users that is hard to defend, so Phase 1.5 must be settled first. Attribution
([as <nick>]) survives regardless and remains the thing Phase 2 cannot ship without.
Recorded as a revised execution-order table rather than a renumbering, so existing
cross-references from plan-agent-task-console.md and plan-agent-workspace-renderer.md
still resolve.
2026-08-23 — IRC is the substrate, not the record; Phase 0.5 added
A review asked whether IRC is a sensible substrate for task orchestration. The answer
that survived contact with the source: IRC is a good transport and viewing surface
and a poor system of record, and this plan had already reached that conclusion
without stating it — it puts a typed API in front, records “IRC is better understood as
the substrate than the API,” and specifies an ask path with no channel at all.
Everything this plan praises about “the IRC stack” — a deterministic Go dispatcher, no
LLM in the dispatch decision, ephemeral pods, one write authority, a typed event
vocabulary — is a property of agent-controller, not of IRC. What IRC supplies and
should keep is the multi-client surface, the human-readable transcript, and pub/sub.
What it supplies and should not is the database.
Four source findings made that concrete (full evidence in Phase 0.5): a 15-channel registration cap the controller silently exceeds because it reads no NOTICEs, a one-week history TTL, a firehose regex-scraped out of chat prose, and firehose events over 400 bytes splitting into invalid JSON that the reader drops without logging. Finding 3 is the structural one — the no-new-string-markers rule adopted on 2026-08-22 is unenforceable while a worker’s only path to the controller is typing into a channel.
Added Phase 0.5: SQLite on the controller’s PVC, IRC demoted to a projection, and a typed worker→controller ingress carrying the existing firehose vocabulary. Numbered 0.5 rather than 1 because it is independent of Phase 0 and can land in either order; Phase 0 is breadth, this is the foundation Phases 2–5 build on. The cost is retiring “the channel is canonical” — recorded as an honest cost alongside the API’s, on the grounds that this plan already breaks the weaker form of the same invariant.
Readiness checked against the live cluster, same day. Both phases can start. Phase 0
is unblocked outright, and its secret wiring turned out to be a kv patch rather than a
manifest change, with an OpenRouter key already in the org. Phase 0.5 corrected one
error in its own first draft: it claimed SQLite would live on “the controller’s existing
PVC,” and the controller has no PVC — it runs readOnlyRootFilesystem: true and writes
nothing. The phase creates one. Offsetting that, replicas: 1 + strategy: Recreate
are already in place, so the single-writer constraint SQLite needs is already the
controller’s design rather than an argument to win.
Explicitly unchanged by this: the 2026-08-22 rejection of FIPA-lite stands. Phase 0.5 introduces no new message schema and no new vocabulary — it gives the firehose contract a durable home and a typed ingress. The instinct behind FIPA was right; the answer is still the firehose, not speech acts.
2026-08-22 — the split already exists; make Slack a client of it
Comparing the three orchestrators (munder-difflin’s god agent, pi-mom, agent-controller) made it clear that the IRC stack already draws the Go/TypeScript boundary in the right place — deterministic dispatcher, LLM-only harness, ephemeral pods, IRC as record. The work is not to design an architecture but to stop running a second one.
2026-08-22 — typed API over IRC-speaking clients
Chose a typed HTTP API over having every client speak IRC as a privileged proxy.
Accepts breaking the “all control happens via IRC” invariant, on the grounds that IRC
is better understood as the substrate than the API. Mitigated by putting the listener
in a separate, RBAC-less process — reusing the agent-controller reaper subcommand
pattern — so the k8s-privileged process keeps its “no inbound listener” property.
2026-08-22 — provider unpinning promoted to Phase 0
Originally scoped as a follow-up. Promoted to prerequisite on discovering that
models.go, spawn.go:248 and spawn.go:250 pin the worker runtime to Anthropic:
every other phase moves work onto that runtime, so migrating first would mean
migrating onto a narrower LLM surface than we are leaving — the opposite of the goal.
2026-08-22 — turnstone resolves the review-gate and thread-routing questions
turnstone/channels/slack/ serves interactive Block Kit approvals over
AsyncSocketModeHandler — so approve/deny and for_review accept/reopen need no
inbound port and no public Request URL. It also supplies proven answers to two things
this plan had listed as unresolved: a persisted (channel, user_id?, thread_ts?)
route key (the thread-collision fix) and a channel_users link table with a
self-service /link modal (the attribution prerequisite). Adopted as design
precedent; whether turnstone should also be the implementation is now an open
question rather than an assumption.
2026-08-22 — pi-mom stays; turnstone is reference, not implementation
Considered replacing pi-mom with turnstone’s channel adapter, which already ships the
approvals, identity linking, and persisted thread routes this plan needs. Rejected:
the current Slack implementation is ours to change and adapt — four patches deep and
proven malleable — whereas adopting turnstone’s adapter would mean bending to a third
orchestration model (Python, its own workstream abstraction) against the stated
pi-mono/TypeScript preference. turnstone is untouched by this plan and cited only as
a working precedent. The cost of the choice is that the interactive mechanics get
reimplemented in TypeScript rather than inherited — and that pi-mom’s Socket Mode
client must be confirmed to handle block_actions, now an open question.
2026-08-22 — rejected FIPA-lite speech acts; standardize on the firehose instead
Considered adopting munder-difflin’s FIPA-lite message schema for the sake of using a
standard. Rejected: its hops/requires_reply machinery exists to stop peer agents
looping, and this design has no peer-to-peer messaging; FIPA-ACL has been dormant
since the mid-2000s, so it offers no interop or tooling. The underlying instinct was
sound, though — the in-channel string markers are accreting and unversioned. Adopted
the rule that new signals become typed #firehose event kinds, not new markers.
2026-08-22 — verified against the pi monorepo; Phase 0 shrinks
/operator/repos/pi confirmed that pi-ai ships ~40 providers including openai,
openrouter, and azure-openai-responses (“Azure OpenAI” — MS Foundry). The
provider-agnosticism this plan is built toward already exists in the harness; the
Anthropic pin is ours alone and lives entirely in agent-controller’s Go. Phase 0 is
therefore three Go edits plus secret wiring, with no TypeScript change and no
dependency bump — smaller and lower-risk than first written.
Two things surfaced alongside it. Upstream has rescoped to @earendil-works/pi-* and
moved to 0.84.2 against our 0.73.1/0.70.6 pins, which makes the next dependency update
a rename-and-bump; recorded as a risk, explicitly not a prerequisite. And upstream
has grown pi-server/pi-protocol/pi-client plus a SQLite session backend, which
would principledly fix the wrapper’s in-memory state and thread collisions — but both
packages are marked experimental, so it is an open question rather than a phase.
2026-08-22 — pi-mom is orphaned upstream; pi-chat dropped Slack
Cloning earendil-works/pi-chat to answer the block_actions question instead
answered a bigger one: pi-chat bridges Telegram and Discord only — “slack” does
not appear anywhere in its tree — and @mariozechner/pi-mom has no source in either
upstream repo. The vendor’s chat direction is a pi extension with per-channel Gondolin
micro-VMs, and Slack did not come along.
This does not reverse the decision to keep pi-mom (it is still ours to change, which
is why it was chosen), but it changes what that costs: no upstream to contribute to,
no upgrade path for the 0.70.6 pin, and any interactivity patch lands in the
compiled dist/ of an unmaintained package. Recorded as the plan’s most significant
risk, with pi-chat’s DiscoveryProvider / LiveConnection seams noted as an escape
hatch — heavily qualified, since that route supplies no interactivity primitives and
drags in QEMU-per-channel.
The same search turned up the constructive half: pi-agent-core’s lanes model,
whose documented worked example is a Slack thread. A lane per thread over shared
history, with at most one operation per lane, is a better answer to the concurrent-
thread collision than the route-key triple this plan had borrowed from turnstone —
conditional on lanes existing at our pinned version.
2026-08-22 — pre-implementation verification pass
Worked the plan’s flagged unknowns against /operator/repos/pi. Three resolved, one
outstanding.
Resolved. (1) The harness is already provider-agnostic — pi-ai ships ~40 providers
including openai, openrouter, and azure-openai-responses; Phase 0 is Go-only.
(2) Lanes do not exist at our 0.73.1 pin — harness v2 is v0.84.0+ — so the
threading fix stays tactical and the bump is not on this plan’s critical path.
(3) The 0.73.1 → 0.84.2 bump is far smaller than the version gap implies: everything
the wrapper calls survives, and only three items apply (scope rename at 0.74.0,
Node ≥22.19.0 at 0.75.0, required streamFunction at 0.81.0).
Outstanding. pi-mom’s block_actions support, answerable only against the running
pod. Everything through Phase 3 can start without it.
Also settled by decision rather than evidence: pi-mom stays as-is. The orphaning finding is real but not disqualifying, given a Slack adapter PR in flight for pi-chat.
2026-08-22 — work shape is per-request; shared tier gets promoted
Two corrections from review. First, the plan had bound each capability to one work
shape and asked whether accessibility-tester should “become a task” — a malformed
question. A capability declares which shapes it supports; the caller picks per
request. An a11y audit is a task (minutes of work, a durable report, a for_review
that means “a human read the findings”); a single-page spot-check is an ask. The
registry now expresses shapes as a list.
Second, the tree still encodes the two-stack split this plan removes. Added a target
layout promoting agent-controller, dashboard-backend, the renderers, the
capability registry and the event schema into a shared tier — extending the pattern
base/ and agents/ already establish — with slack/bot, channel-tailer,
relay-client and ergo config correctly staying client-specific. Config splits on
“would an IRC-only deployment need this?”: capability definitions shared, channel
scoping per client. Surfaced one duplication to reconcile rather than sort —
web-developer-channel-repos.yaml and IRC’s !hub/!repo are the same repo-binding
concept implemented twice. Directory moves are explicitly sequenced to ride the change
that makes them true, never as a standalone reorg.
Last updated: 2026-08-24