Skip to content

fix(runtime): package prompt resources and fail closed on load errors - #29

Merged
aurascoper merged 2 commits into
docs/eeg-methods-scopefrom
fix/packaged-prompt-resources
Jul 24, 2026
Merged

fix(runtime): package prompt resources and fail closed on load errors#29
aurascoper merged 2 commits into
docs/eeg-methods-scopefrom
fix/packaged-prompt-resources

Conversation

@aurascoper

Copy link
Copy Markdown
Owner

Fixes a SIGTRAP crash in the packaged macOS app, and closes the empty-prompt fallback that would otherwise replace it.

Observed failure

/Applications/NeuralCompose.app (0.1.0, arm64) trapped on the main thread:

_assertionFailure
closure #1 in variable initialization expression of static NSBundle.module
one-time initialization function for module
_dispatch_once_callout
PromptProfile.load()
LiveRuntimeFactory.make(runtimeName:model:systemPrompt:ollamaBaseURL:)
AppViewModel.ensureHypnagogicLoopRunning()

Launched 15:30:22, crashed 15:30:37 — consistent with the opt-in hypnagogic loop being enabled after launch, not an app-start failure.

Root cause

Package.swift gives BCICloudBridge resources: [.process("Prompts")], which emits NeuralCompose_BCICloudBridge.bundle. Scripts/package-app-bundle.sh copied exactly one bundle by hardcoded name (the MLX one), so the prompt bundle never reached the .app.

Bundle.module is a static let whose generated initializer calls fatalError(). It traps during the lazy global's dispatch_once, before any Swift error exists — so the existing guard ... else { throw .missingResource } on the following line was unreachable, and try? at the call sites could not recover.

Neither swift build nor swift test could catch this: SwiftPM leaves the bundle beside the binary, where lookup succeeds. Only the packaged layout was broken, and nothing exercised it.

Two commits, one inseparable PR

A — fix(runtime): fail closed when prompt resources are unavailable
PromptResourceLocator (nonfatal, injectable, explicit candidate roots; no reachable Bundle.module), typed missing-bundle / missing-file / empty-prompt errors, removal of all (try? ...) ?? "" prompt fallbacks, throwing runtime initializers, loop-disable on resolution failure, corrected custom-prompt identity and hash, plus tests.

Safe on its own: without the packaging fix a packaged app reports the feature unavailable instead of crashing or sending an empty prompt. Verified standalone — builds clean, 180 tests pass.

B — fix(packaging): include SwiftPM resource bundles in macOS app
Generic top-level *.bundle sweep, pwd -P resolution of the .build/<config> symlink (find will not descend into a symlink root), required-bundle assertion before signing, copy-before-codesign, smoke-packaged-resources.sh, and PackagedAppResourceTests.

Resolves

R17_packaged_bundle_SIGTRAP: fixed
R4_empty_prompt_fallback: fixed
R5_prompt_hash_mismatch: fixed
cloud_fallback_on_resolution_failure: removed
new_runtime_authority: false

Tests

affected_and_introduced_tests: 216_passed
BCIEEGTests:
  status: pre_existing_environment_failure
  observed: 39_passed_then_process_exit
  counted_as_pass: false

BCICoreTests 4 · BCIClassifierTests 13 · BCICloudBridgeTests 65 · BCIVoiceTests 21 · NeuralComposeAppTests 113 — all 0 failures. ./Scripts/build.sh, codesign --verify --deep --strict, and git diff --check all pass.

BCIEEGTests exits 1 in MindMonitorOSCStreamTests (UDP sockets in a sandboxed shell). Reproduces identically on the origin/main baseline; pre-existing and unrelated, and not counted as a pass.

Merge gate

The locator tests exercise the exact boundary that failed and are confirmed not to skip, but an operator-attended packaged launch is still required before merge: build and package from these two revisions, verify both bundles and all three prompts, launch with synthetic board data and the hypnagogic path enabled against a local or deliberately-invalid runtime, and confirm over 30–60s that the process survives the former ~15s crash window with no SIGTRAP, no empty system prompt, no implicit Claude substitution, and the UI reflecting a disabled loop when resolution fails. TCC prompts may appear; do not weaken permissions to automate it.

Out of scope

Deliberately not included, per the agreed stabilization order — phase 2: R9 (Python in CI), R1 (missing structured_state), R16 (SoakRuns/.gitignore); phase 3: R2 (/usr/bin/env argv), R3 (dedicated Witness runtime), R8 (truthful egress UI). No EEG, JEPA, fusion, or model work. R3's dead systemPrompt` field is flagged in a code comment so it is not mistaken for working wiring.

Base is docs/eeg-methods-scope, not main: that is where the defect was introduced and the first segment of the integration train.

🤖 Generated with Claude Code

aurascoper and others added 2 commits July 24, 2026 16:45
A packaged /Applications/NeuralCompose.app crashed with SIGTRAP on the main
thread inside SwiftPM's generated NSBundle.module accessor, reached from
PromptProfile.load() -> LiveRuntimeFactory.make() ->
AppViewModel.ensureHypnagogicLoopRunning().

Bundle.module is a `static let` whose generated initializer calls
fatalError() when its resource bundle is absent, so it traps during the lazy
global's dispatch_once -- before any Swift error exists. The existing
`guard let url = Bundle.module.url(...) else { throw .missingResource }` was
therefore unreachable whenever the whole bundle was missing, and `try?` at the
call sites could not recover. Absence has to be a value, not an assertion.

Replace it with PromptResourceLocator: an injectable, nonfatal search over
explicit candidate roots covering the packaged .app (Contents/Resources and
the Contents-level symlink), plain SwiftPM builds (the bundle sits beside the
executable), and the XCTest layout. Both the flat bundle SwiftPM actually
emits and the conventional Contents/Resources nesting resolve. Bundle.module
is no longer referenced on any executable path.

Missing bundle, missing file, and empty prompt are now distinct typed errors,
and every `(try? ...) ?? ""` prompt fallback is gone. An empty system prompt
is never a usable value: `claude -p --system-prompt ""` is an unconstrained
model on the one deliberate network-egress path, and OllamaHTTPTransport drops
its delimiter entirely when the system prompt is empty. Runtime initializers
throw, and both generate() paths keep a backstop check at the last gate before
bytes leave the process.

AppViewModel no longer substitutes a cloud ClaudeCLIGenerator when runtime
resolution fails -- a packaging failure or a mistyped NEURALCOMPOSE_RUNTIME
previously produced network egress the user never selected. It now disables
the opt-in loop and surfaces the typed error.

Also corrects the generator fingerprint on the system-prompt-override path,
which claimed promptProfile "wakingDialectical" and hashed that file no matter
what was sent, so telemetry attested to bytes that never left the process. It
now reports "custom" and hashes the transmitted prompt. One existing test was
asserting that behaviour and has been updated to pin the correct semantics.

This commit is safe without the packaging change that follows: a packaged app
missing the bundle reports the feature unavailable instead of crashing or
sending an empty prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restores the resource bundle whose absence produced the packaged-app SIGTRAP
fixed in the preceding commit.

Any target declaring `resources:` in Package.swift emits a
<Package>_<Target>.bundle next to the built binary. package-app-bundle.sh
copied exactly one of them by hardcoded name (mlx-swift_Cmlx.bundle), so when
BCICloudBridge gained `resources: [.process("Prompts")]` the prompt bundle
never reached the .app. Sweep every top-level *.bundle instead of naming one.

.build/<config> is a symlink to an architecture-specific directory, and find
will not descend into a symlink given as its root, so the build directory is
resolved with `pwd -P` first -- otherwise the sweep silently copies nothing.

A pre-signing assertion now fails the packaging run when
NeuralCompose_BCICloudBridge.bundle is missing, so a regression is a build
failure here rather than a user-visible one. Copying happens before
`codesign --force --deep`, which must run last or it seals a bundle that no
longer matches its signature; signing behaviour is otherwise unchanged.

Neither `swift build` nor `swift test` could catch this: SwiftPM leaves the
bundle beside the binary where lookup succeeds, so only the packaged layout
was broken and nothing exercised it. PackagedAppResourceTests drives the real
PromptResourceLocator against the real packaged .app, and
smoke-packaged-resources.sh builds, packages, verifies the layout and
signature, runs those tests while insisting they did not skip, and checks the
negative case by removing the bundle and confirming packaging exits non-zero.

The smoke test deliberately does not launch the GUI app: that needs
window-server access and raises TCC prompts for mic and speech. An
operator-attended packaged launch remains a merge gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aurascoper

Copy link
Copy Markdown
Owner Author

Packaged gate — evidence

Run against 968dbab887d0b7f78dc9b526b5b8fe921607a98d.

manual_packaged_launch:
  commit: 968dbab887d0b7f78dc9b526b5b8fe921607a98d
  resource_smoke: passed
  codesign_verify: passed
  board_profile: synthetic
  runtime: ollama
  model: __promptfix_missing_model__
  observation_seconds: 70
  process_survived: true
  sigtrap: false
  new_crash_report: false
  empty_prompt_generation: false
  loop_failed_closed: not_verified_in_app     # see caveat
  implicit_claude_fallback: not_observed      # code path removed; not process-monitored

Verified

  • Resource smoke passed end to end: prompt bundle present, all three prompts non-empty, second SwiftPM bundle (swift-transformers_Hub.bundle) copied, codesign --verify --deep --strict passed, packaged-layout loader tests executed (not skipped), and the negative case exits non-zero with the bundle removed.
  • Packaged binary launched with synthetic board data and a deliberately-absent Ollama model. Survived 70 s, versus the original trap at ~15.5 s. No SIGTRAP.
  • No new crash report. The only NeuralCompose*.ips present is still the original …-153040.ips from the crash this PR fixes.

The decisive check

The unified log produced no records for the process, so process survival alone could not prove the resolution path was reached. To close that gap the real dialectic-session binary was run from inside the packaged .app (so Bundle.main is the app — the exact failing configuration), exercising RuntimeFactory.makePromptProfile.load():

condition exit behaviour
prompt bundle present 0 runtime report printed; prompts loaded from the packaged bundle
prompt bundle removed 1 typed PromptProfileError naming the missing resource and the searched roots

Exit 1, not 133/SIGTRAP. That is the previously-uncatchable fatalError converted into a recoverable typed error, demonstrated in the real packaged layout. The harness binary was removed afterwards and the app re-packaged and re-verified.

Caveat — the gate is not fully discharged

The in-app state transition remains unverified. ensureHypnagogicLoopRunning() awaits mic/speech authorization and returns early if it is not granted — before reaching LiveRuntimeFactory.make. With no operator present to accept the TCC prompt, the loop most likely never started, so the 70 s survival is not by itself proof that the app failed closed. No permissions were weakened and no bypass was added to work around this.

Still needed from an operator at the machine: launch the packaged app, enable the hypnagogic loop via the UI, accept the TCC prompt, and confirm the toggle returns to disabled with the typed error surfaced. The mechanism is proven above; what is outstanding is the UI-level confirmation.

Leaving this PR in draft until then.

@aurascoper

Copy link
Copy Markdown
Owner Author

Operator-attended launch — gate discharged

Operator launched the packaged app and enabled the hypnagogic loop.

manual_packaged_launch:
  build: packaged .app from 968dbab
  loop_enabled_by_operator: true
  uptime_at_check: 5m44s        # original trap was at ~15.5s
  sigtrap: false
  new_crash_report: false       # only the original …-153040.ips remains

The loop running is the proof. ensureHypnagogicLoopRunning() reaches LiveRuntimeFactory.make(...)PromptProfile.load() only after the auth gate, so a running loop means the prompt resources resolved from the packaged bundle at the exact call site that previously trapped inside NSBundle.module.

This is the success path. The in-app failure transition (toggle returning to disabled with the typed error surfaced) was not exercised in this run, because resolution succeeded. That mechanism is covered by the packaged-harness negative test reported above: bundle removed → exit 1 with a typed PromptProfileError, not 133/SIGTRAP.

Publication gate:

github_ci: success
manual_packaged_launch: pass
resource_smoke: pass
codesign_verify: pass
new_crash_report: false
worktree_clean: true

Marking ready for review. Rebase merge into docs/eeg-methods-scope preserves both bisect-safe commits.


Unrelated to this diff, noted for the record: Models/ is gitignored and holds no artifacts from this PR. A separate observation about absent local model directories is being tracked outside this branch.

@aurascoper
aurascoper marked this pull request as ready for review July 24, 2026 22:07
@aurascoper
aurascoper merged commit 0d03e76 into docs/eeg-methods-scope Jul 24, 2026
1 check passed
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