LearnNewsExamplesServices
Frontmatter
id18556
titleApp Worker errors reach no sink that does not require a Brain checkout
stateClosed
labels
bugaiarchitecturecore
assigneesneo-opus-ada
createdAtSep 9, 2026, 8:18 PM
updatedAtSep 9, 2026, 10:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/18556
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 9, 2026, 10:20 PM

App Worker errors reach no sink that does not require a Brain checkout

Closed v13.2.0/archive-v13-2-0-chunk-20 bugaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Sep 9, 2026, 8:18 PM

Context

#18551 broke every Workstation viewport vdom sync and shipped with 24/24 CI green. @tobiu found it by opening the app in a browser. Investigating why nothing caught it turned up a cause underneath the missing assertions: an App Worker error has nowhere to go that does not require a private Brain checkout.

Split off #18554, which owns the test gate. That gate cannot be built until this channel exists, and this channel is worth having on its own.

The Problem

A Neo app raises its errors in the App Worker. src/worker/App.mjs:397 wraps console[log|warn|error|info] and :445 hooks globalThis.onerror. Both route exclusively to Neo.ai?.Client — the Neural Link bridge. The wrapper does still call original.apply(console, args), but in SharedWorker mode that writes to the SharedWorker's own inspector context, which the page never sees.

So an App Worker error is visible to exactly two audiences:

  1. a developer who knows to open the SharedWorker's separate DevTools context, and
  2. a Neural Link session, which needs NEO_AGENTOS_RUNTIME_ROOT pointing at a neo-agent-brain checkout.

It is visible to nobody else — not the page console, not a browser-driving test, not a developer who simply has the app open. #18551 spent its whole life in that blind spot: the throw was caught by VdomLifecycle.mjs:378 and logged, and the log went where only the Brain could read it.

This is a channel gap, not a missing assertion. No quantity of added test assertions can observe a signal that never arrives.

The Architectural Reality

  • src/worker/App.mjs:397 — interceptConsole(), the console wrapper. Sink: Neo.ai?.Client only.
  • src/worker/App.mjs:445 — globalThis.onerror. Sink: Neo.ai?.Client only.
  • src/Main.mjs — the remote.app manifest already exposes log, and Main.log(data) is console[data.method || 'log'](data.value). It has zero callers in the repository. The channel this ticket needs already exists and is dead code.
  • src/worker/mixin/RemoteMethodAccess.mjs:generateRemote — every remote proxy returns promiseMessage, which rejects with NEO_DEAD_PORT when a window closed. A forward must swallow that.
  • src/worker/Base.mjs:450 — an unrouted 'main' destination emits console.warn about its own deprecation under SharedWorker. That warning would arrive back through the interceptor doing the forwarding.

The Fix

In interceptConsole(), mirror error-level output and the onerror payload to the main thread via the existing Neo.Main.log, addressed per connected window.

Nothing is added to any manifest and no new addon appears; the only new surface is one protected method plus a re-entrancy latch. Serialisation moves out of the Brain-client branch into serializeConsoleArgs so the mirror does not depend on a client being attached — that independence is the entire point.

Constraints the shape has to respect, each for a checked reason rather than a guessed one:

  • Errors only. Volume with no consumer is noise, and warn is what the deprecated-destination path emits.
  • Never in production. A diagnostic mirror, not a logging transport.
  • Addressed per windowId. Avoids the deprecated unrouted 'main', whose warning would re-enter the interceptor.
  • Re-entrancy latched and rejections swallowed. The mirror runs inside console.error; anything it logs comes back to it.

Acceptance Criteria

  • AC-1 — an App Worker error reaches the page console. Verified against a real defect, not a synthetic throw: with #18551's syncTopologyBar reinstated on a local branch, the Workstation boot produces App Worker: … Component not found for id: neo-component-34 in the page console, with no Brain checkout and no Neural Link.
  • AC-2 — a healthy app mirrors nothing. Absence control, in CI. Without it, AC-1 would be satisfied by a mirror that fired on everything, which is as useless as one that never fires.
  • AC-3 — level and environment are both bounded. log/warn/info are not mirrored, and nothing is mirrored when Neo.config.environment is dist/production.
  • AC-4 — the mirror cannot become a fault of its own. A dead port rejects without logging; a re-entrant call is latched out. Both are reachable states, not hypotheticals: NEO_DEAD_PORT is ordinary window teardown, and the unrouted-destination warning is one line away in worker/Base.mjs.
  • AC-5 — no new remote surface. git grep shows the Main remote.app manifest unchanged; the forward uses the log entry that already existed with no callers.

Out of Scope

  • The test gate that fails a spec on a mirrored error. #18554 owns it, and it stays open. It is not bundled here because its own failure path cannot yet be exercised in CI: the App-Worker remote manifest exposes no way to provoke a worker error, and destroyNeoInstance correctly routes through parent.remove(), so no public path produces an orphan stub. Shipping a guard whose red path is unproven is the failure this whole thread is about.
  • unhandledrejection. Not currently hooked by interceptConsole either, so mirroring it would be new coverage rather than a new path for existing coverage. Worth its own look; named here so it is not mistaken for an oversight.
  • The 'App Worker: ' prefix coupling. The mirror writes it in the browser and #18554's gate would match it in Node; they cannot share an import. Guarding that pair belongs with the gate that depends on it.
  • Changing VdomLifecycle.mjs:378. Logging rather than throwing is the documented intent.

Avoided Traps

  • Adding a new remote method or a Main addon. Main.log already had exactly this shape and no callers. Adding a second one would have been accretion on top of dead code.
  • Mirroring every level. Turns the page console into a worker log tail and gives a future gate nothing it can assert on.
  • Attaching a CDP session to the shared_worker target from the harness instead. No engine change, but Chromium-only harness plumbing that helps no human developer and leaves the underlying invisibility in place.

Decision Record impact

none. Adds a development-only diagnostic path over an existing remote method; no runtime contract changes.

Related

  • #18554 — the test gate this unblocks; stays open
  • #18551 / #18552 — the regression that exposed the gap, and its fix
  • #18545 / #18546 — where the defect was introduced

Handoff Retrieval Hints

  • query_raw_memories: "app worker console error main thread mirror Neo.Main.log dead remote method"
  • Anchors: src/worker/App.mjs:397 + :445 (the two Brain-only sinks), src/Main.mjs remote.app (log, zero callers), src/worker/Base.mjs:450 (the deprecation warning that re-enters), RemoteMethodAccess.mjs:generateRemote (why rejections must be swallowed)

Origin Session ID: 0dd7d644-f279-4b53-a515-522346f5f28f