Skip to content

opencode builders are not reliably reachable — 4 fixes on our side of the line #195

Description

@pseudoseed

Grok is fine. opencode's integration with our terminal layer is not. Every failure below is
on our side of the line and fixable without changing opencode.

What happened, 2026-08-29

One workspace, three concurrent builders: spir-146 (claude), air-180 (codex),
bugfix-189 (opencode/grok-4.6). Identical architect messaging to all three.

claude codex opencode
Messages delivered on first attempt all all none
Longest hold 12m, never delivered
Sessions lost 0 0 2
Context recovered on restart n/a n/a none — no conversation resume

Holds classified no-composer-marker, so the render gate could not bound a composer at all.
Tower log, one instance:

16:22:09  Message held (busy): architect → builder-bugfix-189
16:23:10  [mailbox] ESCALATED (held 60s) — visibility only, not delivered
16:25:10  [mailbox] STARVATION notice (1 held ~180s)
16:25:35  Interrupt (ESC) sent — manual, by the architect
16:25:36  delivered

Consequence, not just annoyance. An 8-minute hold meant PR #192 opened missing a fix that had
been requested before it opened, costing a full extra review round. The same one-line change,
after switching that builder to another harness, was delivered, made and verified in 3 minutes.

Four fixes, ours to make

1. Re-measure the opencode gate profile (most likely root cause)

OPENCODE_PROFILE in gate-profiles.ts anchors on literal box-drawing:

rulePattern: /^\s*{5,}/,
bodyPattern: /^\s*/,
topEdgePattern: /^\s*$/,
minContentRows: 3,

Its comments say these were measured across seven states, so this was right when written. Those
patterns are exact glyphs from a specific opencode release, and opencode has shipped since.
no-composer-marker is precisely what a changed rule glyph or chrome row produces.

Hypothesis, not verified — I have not captured current frames. First task is the capture, not
the patch: dump real frames from a live opencode builder in idle, mid-turn and dialog states and
diff them against these four patterns. If they still match, the cause is elsewhere and this issue
needs re-diagnosing rather than a profile edit.

A profile that drifts silently is the deeper problem: add a test that fails when a captured
fixture stops matching, so the next opencode release breaks CI instead of breaking delivery.

2. Never send Ctrl+C to a harness that exits on it

afx send --interrupt sends Ctrl+C unconditionally. claude and codex treat it as "pause";
opencode treats it as "quit" — verified, session gone 30s after delivery. That is what killed
one of the two sessions, and the operator has no way to know from the flag's name.

Add a per-harness interrupt capability (interruptSignal: 'esc' | 'ctrl-c'). On a harness that
exits, --interrupt should refuse with a named reason or downgrade to ESC — never send a signal
recorded as fatal for that app.

3. Deliver held mail through the filesystem when the screen cannot be read

Today, mail the gate cannot verify waits indefinitely. But a message that cannot be typed can
still be written: drop held messages into .builders/<id>/codev/state/inbox/ and have the
builder protocol prompt check it. Harness-independent, survives restarts, and bypasses screen
recognition entirely — the gate stops being a single point of failure for every instruction.

This is the durable fix. #1 makes opencode work; #3 makes the next unmeasured TUI work.

4. Warm restart for harnesses with no conversation resume

afx spawn <id> --resume on opencode prints "This harness does not support conversation resume;
starting a fresh session." Both times, the replacement had no idea work was already committed, and
only a hand-written architect briefing stopped it starting over.

We cannot give opencode conversation resume. We can stop the restart being a blank slate: the
builder's own narrative log already exists at codev/state/<id>_thread.md. Inject it as opening
context on a no-resume restart, alongside git log for the branch. Turns total context loss into
a warm start.

Suggested order

2 first — smallest, and it is the one that destroyed work. 1 next — the capture is a
diagnostic, cheap, and either confirms drift or redirects this issue. 4 — makes the failure
survivable. 3 — the real fix, and worth doing whether or not 1 succeeds.

What is not the problem

Grok's output. The fix it wrote under all this (PR #192) was approved 3/3 by CMAP and held up on
my own read: an allowlisted scrub, re-applied per worker, with tests that assert their own reach.
The model did its job; it just could not hear us.

Related: #190 (the starvation notice promises a recovery that cannot fire for a busy agent).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/towerTower, afx, terminals, messaging

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions