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:
- a developer who knows to open the SharedWorker's separate DevTools context, and
- 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
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
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:397wrapsconsole[log|warn|error|info]and:445hooksglobalThis.onerror. Both route exclusively toNeo.ai?.Client— the Neural Link bridge. The wrapper does still calloriginal.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:
NEO_AGENTOS_RUNTIME_ROOTpointing at aneo-agent-braincheckout.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:378and 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?.Clientonly.src/worker/App.mjs:445—globalThis.onerror. Sink:Neo.ai?.Clientonly.src/Main.mjs— theremote.appmanifest already exposeslog, andMain.log(data)isconsole[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 returnspromiseMessage, which rejects withNEO_DEAD_PORTwhen a window closed. A forward must swallow that.src/worker/Base.mjs:450— an unrouted'main'destination emitsconsole.warnabout its own deprecation under SharedWorker. That warning would arrive back through the interceptor doing the forwarding.The Fix
In
interceptConsole(), mirrorerror-level output and theonerrorpayload to the main thread via the existingNeo.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
serializeConsoleArgsso 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:
warnis what the deprecated-destination path emits.windowId. Avoids the deprecated unrouted'main', whose warning would re-enter the interceptor.console.error; anything it logs comes back to it.Acceptance Criteria
syncTopologyBarreinstated on a local branch, the Workstation boot producesApp Worker: … Component not found for id: neo-component-34in the page console, with no Brain checkout and no Neural Link.log/warn/infoare not mirrored, and nothing is mirrored whenNeo.config.environmentisdist/production.NEO_DEAD_PORTis ordinary window teardown, and the unrouted-destination warning is one line away inworker/Base.mjs.git grepshows the Mainremote.appmanifest unchanged; the forward uses thelogentry that already existed with no callers.Out of Scope
destroyNeoInstancecorrectly routes throughparent.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 byinterceptConsoleeither, 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.'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.VdomLifecycle.mjs:378. Logging rather than throwing is the documented intent.Avoided Traps
Main.logalready had exactly this shape and no callers. Adding a second one would have been accretion on top of dead code.shared_workertarget 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
Handoff Retrieval Hints
query_raw_memories: "app worker console error main thread mirror Neo.Main.log dead remote method"src/worker/App.mjs:397+:445(the two Brain-only sinks),src/Main.mjsremote.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