Skip to content

fix(opencode): make the notice tracker's dispose terminal - #1540

Merged
backnotprop merged 1 commit into
mainfrom
fix/opencode-v2-tracker-dispose
Sep 15, 2026
Merged

backnotprop merged 1 commit into
mainfrom
fix/opencode-v2-tracker-dispose

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Verification QA follow-up on #1536. Two findings on createNoticePendingTracker, both about its lifecycle rather than its event reading.

LOW — dispose() was not terminal

stop() aborts the controller and sets it back to undefined, which is exactly
the state watch() treats as "not watching yet":

if (controller || typeof subscribe !== "function" || !sessionID) return;

So aborting alone cannot express "never again". Both production call sites
dispose in a finally AFTER the review returns (native-commands.ts,
server.ts) and toastPlannotatorUrl invokes notifyUrl fire-and-forget
without awaiting it, so a notice that outlives the command would re-open a fresh
ctx.event.subscribe() on the host with no owner left to abort it — the exact
leak dispose() was added to prevent.

Unreachable on main today (the CLI child's stderr pump and ready-file poller are
torn down inside runCli's own finally before handleCliCommand resolves), so
this is latent: a detached ready poller, a notice retry, or any future reuse of
one V2BridgeClient across two invocations makes it live.

Fix. A disposed latch, set only by dispose (never by settle, which
legitimately just stops the watch). posting() returns early when it is set, and
admitted() arms nothing — after dispose there is no watcher left that could
ever lower the flag again, which is the stale "still pending" the tracker exists
to rule out.

INFO (pre-existing) — the arm happened after the host round-trip

pending was raised only in admitted(), i.e. AFTER await synthetic({...}),
while toastPlannotatorUrl never awaits the returned promise. For the whole
round-trip the flag read false, so feedback delivered in that window would be
admitted with queue and the notice promoted alone as its own model turn with
the feedback stuck behind it — the #1515 symptom. Carried over unchanged from
#1518; practically unreachable (the notice is posted when the review server
prints its URL, the feedback arrives when the human finishes reviewing), but it
is the one remaining way the co-promotion can silently fail.

It was safe to tighten, so it is tightened: watch on the tracker is now
posting, which starts the watch AND arms provisionally, and the notifier wraps
synthetic so a refusal calls the new rejected() and lowers the arm again.
Both corrections keep the tracker from staying armed on a guess:

  • host accepts with a row id → admitted() sets the real answer (including the
    already-settled case, where it lowers the arm);
  • host accepts with no row id → still pending, exactly as before;
  • host refuses → rejected() lowers it, which the pre-existing "a rejected
    notice leaves the feedback queued" test already pinned and still pins.

Testing

  • bun test apps/opencode-plugin → 184 pass / 0 fail (was 181), 3 new cases:
    a notice posted after dispose opens no new subscription, feedback delivered
    while the notice is still posting is co-promoted, and a refused notice lowers
    the provisional arm. The first two were confirmed to fail against the
    unmodified v2-client.ts and pass with it.
  • bun run typecheck → clean.
  • bun run build:opencode → clean (after the required apps/review +
    build:hook prerequisites in a fresh worktree).

No OpenCode server was started; the behaviour is exercised through the existing
createTestEventStream harness.

`stop()` aborts the controller and leaves it `undefined`, which is exactly the
state the watcher starts from, so aborting alone could not express "never
again": a `notifyUrl` that outlived `dispose()` re-opened a host event
subscription with no owner left to abort it — the leak #1536 added `dispose()`
to prevent. Both call sites dispose in a `finally` after the review returns and
`toastPlannotatorUrl` never awaits `notifyUrl`, so nothing in the type orders
the two. Unreachable on main today (the CLI child's pumps are torn down inside
`runCli`'s own `finally`), latent the moment notice delivery outlives the
command.

Add a `disposed` latch, set only by `dispose` and never by `settle`: the watcher
returns early and `admitted()` arms nothing, since after dispose no watcher is
left that could lower the flag again.

Also close the pre-existing arming window carried over from #1518. `pending` was
raised only after the un-awaited `session.synthetic` round-trip, so feedback
delivered while the notice was still posting read as not pending and queued
behind a notice that is then promoted alone as its own model turn — the #1515
symptom. `watch` becomes `posting`, which starts the watch AND arms
provisionally; the notifier wraps `synthetic` so a refusal calls the new
`rejected()` and lowers the arm again, which the existing "a rejected notice
leaves the feedback queued" test still pins.
@backnotprop
backnotprop merged commit 11cbc94 into main Sep 15, 2026
28 checks passed
@backnotprop
backnotprop deleted the fix/opencode-v2-tracker-dispose branch September 15, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant