fix(runtime): package prompt resources and fail closed on load errors - #29
Conversation
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>
Packaged gate — evidenceRun against 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-monitoredVerified
The decisive checkThe 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
Exit Caveat — the gate is not fully dischargedThe in-app state transition remains unverified. 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. |
Operator-attended launch — gate dischargedOperator 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 remainsThe loop running is the proof. 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 Publication gate: github_ci: success
manual_packaged_launch: pass
resource_smoke: pass
codesign_verify: pass
new_crash_report: false
worktree_clean: trueMarking ready for review. Rebase merge into Unrelated to this diff, noted for the record: |
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: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.swiftgivesBCICloudBridgeresources: [.process("Prompts")], which emitsNeuralCompose_BCICloudBridge.bundle.Scripts/package-app-bundle.shcopied exactly one bundle by hardcoded name (the MLX one), so the prompt bundle never reached the.app.Bundle.moduleis astatic letwhose generated initializer callsfatalError(). It traps during the lazy global'sdispatch_once, before any Swift error exists — so the existingguard ... else { throw .missingResource }on the following line was unreachable, andtry?at the call sites could not recover.Neither
swift buildnorswift testcould 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 unavailablePromptResourceLocator(nonfatal, injectable, explicit candidate roots; no reachableBundle.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 appGeneric top-level
*.bundlesweep,pwd -Presolution of the.build/<config>symlink (findwill not descend into a symlink root), required-bundle assertion before signing, copy-before-codesign,smoke-packaged-resources.sh, andPackagedAppResourceTests.Resolves
Tests
BCICoreTests4 ·BCIClassifierTests13 ·BCICloudBridgeTests65 ·BCIVoiceTests21 ·NeuralComposeAppTests113 — all 0 failures../Scripts/build.sh,codesign --verify --deep --strict, andgit diff --checkall pass.BCIEEGTestsexits 1 inMindMonitorOSCStreamTests(UDP sockets in a sandboxed shell). Reproduces identically on theorigin/mainbaseline; 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/envargv), R3 (dedicated Witness runtime), R8 (truthful egress UI). No EEG, JEPA, fusion, or model work.R3's deadsystemPrompt` field is flagged in a code comment so it is not mistaken for working wiring.Base is
docs/eeg-methods-scope, notmain: that is where the defect was introduced and the first segment of the integration train.🤖 Generated with Claude Code