Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE: ${{ secrets.SPARKLE_PRIVATE_KEY }}
SWIFTLM: ${{ secrets.SWIFTLM_CLONE_TOKEN }}
SLACK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
for key in CERT CERT_PASSWORD TEAM APPLE_ID APPLE_PASSWORD SPARKLE SWIFTLM; do
for key in CERT CERT_PASSWORD TEAM APPLE_ID APPLE_PASSWORD SPARKLE SWIFTLM SLACK; do
if [ -z "${!key}" ]; then echo "::error::Required release credential unavailable: $key"; exit 1; fi
done
portable:
Expand Down Expand Up @@ -135,7 +136,7 @@ jobs:
python3 -B scripts/run_native_tests.py
--diagnostics-dir build/native-test-diagnostics
--timeout-seconds 1800 --silence-seconds 300 --
swift test --force-resolved-versions --skip-build --filter "EngramTests|EngramMemoryCoreTests|EngramRealityKitTests|PositionVersionTests"
swift test --force-resolved-versions --skip-build --filter "EngramTests|EngramMemoryCoreTests|EngramRealityKitTests|PositionVersionTests|LockedSnapshotTests"
--skip "PerfTests"
--skip "keyBERTKeywordExtraction"
--skip "recall_semanticRelevanceOrdering"
Expand Down Expand Up @@ -454,6 +455,7 @@ jobs:
# Direct dispatch runs the existing native pipeline; it does not depend on
# a GITHUB_TOKEN-created tag triggering a second workflow.
- name: Create GitHub Release
id: publish
run: |
if ! git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then git tag "$RELEASE_TAG" "$EXPECTED_SHA"; fi
git push origin "refs/tags/$RELEASE_TAG"
Expand Down Expand Up @@ -499,7 +501,9 @@ jobs:
git diff --cached --quiet || (git commit -m "Update engram to ${VERSION}" && git push)

- name: Notify Slack
if: github.event_name == 'push' && success() && env.SLACK_WEBHOOK_URL != ''
# Both tag pushes and release-train dispatches announce a published release.
# A later appcast/tap failure must not suppress the announcement.
if: ${{ !cancelled() && steps.publish.outcome == 'success' }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
Expand All @@ -520,7 +524,7 @@ jobs:
# Escape for JSON
CHANGES_ESCAPED=$(echo "$CHANGES" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])')

curl -s -X POST "$SLACK_WEBHOOK_URL" \
SLACK_RESPONSE=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"blocks\": [
Expand All @@ -538,18 +542,21 @@ jobs:
}
}
]
}"
}")
test "$SLACK_RESPONSE" = "ok" || { echo "::error::Slack did not acknowledge the release announcement"; exit 1; }
echo "Slack acknowledged the release announcement."

# Failed releases were silent (the success notifier is if: success()) —
# announce failures too, with a link to the run.
- name: Notify Slack (failure)
if: github.event_name == 'push' && failure() && env.SLACK_WEBHOOK_URL != ''
if: failure() && steps.publish.outcome != 'success' && env.SLACK_WEBHOOK_URL != ''
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -s -X POST "$SLACK_WEBHOOK_URL" \
SLACK_RESPONSE=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"❌ Engram ${RELEASE_TAG} release FAILED — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}"
-d "{\"text\": \"❌ Engram ${RELEASE_TAG} release FAILED — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}")
test "$SLACK_RESPONSE" = "ok" || { echo "::error::Slack did not acknowledge the failure announcement"; exit 1; }

- name: Cleanup keychain
if: always()
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to Engram are documented in this file.

## [Unreleased]

## [0.14.9] - 2026-09-23

### Fixed
- Prevent background and active-use app crashes caused by repeated reads of Galaxy callbacks growing the invocation stack.
- Repair missing Claude Code memory MCP registration on app launch, including GUI launches without Claude on PATH; verify registration and retry failed setup without requiring an app upgrade.
- Keep existing customized MCP servers and other user configuration intact during registration repair.
- Keep memory and graph updates inside one checked transaction and publish bookkeeping only after commit.
- Let Codex learning recover on a later turn when a busy database prevents the write transaction from starting; preserve reconciliation for uncertain writes.
- Recognize verified near-duplicate responses without blocking future learning, and classify startup failures separately from uncertain writes.
- Preserve Codex task identity across APFS device renumbering and provide bounded migration with checks for other active tasks.
- Include published Lattice Core 2.0.7 fixes for concurrent row-ID generation, text bytes, attachment aliases, numeric defaults, and snapshot initialization.
- Send and verify the normal Slack release announcement for release-tool dispatches as well as tag pushes.

## [0.14.8] - 2026-09-19

### Fixed
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

156 changes: 156 additions & 0 deletions Sources/EngramKit/ClaudeMCPRegistration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Foundation
import Darwin

/// Repairs a missing user-scoped registration without replacing a user's server.
/// Call from a background queue: the CLI has a bounded wait, but config I/O is synchronous.
public enum ClaudeMCPRegistration {
public enum Outcome: Equatable, Sendable, CustomStringConvertible {
case alreadyRegistered, registered, preservedExistingServer, invalidConfiguration
case missingMemoryExecutable, claudeNotFound, launchFailed, verificationFailed
case commandFailed(Int32)
case timedOut(cleanupConfirmed: Bool)

public var description: String {
switch self {
case .alreadyRegistered: "already registered"
case .registered: "registered and verified in user configuration"
case .preservedExistingServer: "existing memory server preserved; automatic replacement skipped"
case .invalidConfiguration: "user configuration could not be read safely; retrying on next app launch"
case .missingMemoryExecutable: "memory executable missing; retrying on next app launch"
case .claudeNotFound: "Claude executable not found; retrying on next app launch"
case .launchFailed: "Claude could not launch; retrying on next app launch"
case .verificationFailed: "CLI exited successfully but registration was not verified; retrying on next app launch"
case .commandFailed(let status): "Claude exited \(status); retrying on next app launch"
case .timedOut(let confirmed):
"Claude registration timed out (process exit confirmed: \(confirmed)); retrying on next app launch"
}
}
}

public static func register(
memoryExecutable: URL,
home: URL,
environment: [String: String],
timeout: TimeInterval = 10
) -> Outcome {
register(memoryExecutable: memoryExecutable, home: home, environment: environment,
timeout: timeout, candidates: executableCandidates(home: home, environment: environment))
}

// Injectable candidates keep tests entirely on private fake executables.
static func register(
memoryExecutable: URL,
home: URL,
environment: [String: String],
timeout: TimeInterval,
candidates: [URL]
) -> Outcome {
let config = configurationURL(home: home, environment: environment)
switch registrationState(at: config, memoryExecutable: memoryExecutable) {
case .matching: return .alreadyRegistered
case .other: return .preservedExistingServer
case .invalid: return .invalidConfiguration
case .missing: break
}
guard isExecutable(memoryExecutable) else { return .missingMemoryExecutable }
guard let claude = candidates.first(where: isExecutable) else { return .claudeNotFound }

var env = environment
env.removeValue(forKey: "CLAUDECODE")
env["HOME"] = home.path
env["PATH"] = executableCandidates(home: home, environment: environment)
.map { $0.deletingLastPathComponent().path }.joined(separator: ":")

let process = Process()
process.executableURL = claude
process.arguments = ["mcp", "add", "--scope", "user", "--transport", "stdio",
"memory", "--", memoryExecutable.path]
process.environment = env
process.currentDirectoryURL = home
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do { try process.run() } catch { return .launchFailed }
// Darwin Foundation launches Process in a separate group. Verify that
// ownership before ever using a negative PID: never signal our app's group.
let childPID = process.processIdentifier
let ownedGroup = getpgid(childPID) == childPID ? childPID : nil

if finished.wait(timeout: .now() + max(0, timeout)) == .timedOut {
guard let ownedGroup else {
// Fail conservatively if a future Foundation implementation does
// not isolate the child. Direct-child exit cannot prove tree cleanup.
if process.isRunning { process.terminate() }
if finished.wait(timeout: .now() + 1) == .timedOut {
if process.isRunning { kill(childPID, SIGKILL) }
_ = finished.wait(timeout: .now() + 1)
}
return .timedOut(cleanupConfirmed: false)
}
kill(-ownedGroup, SIGTERM)
if waitForExit(process, ownedGroup: ownedGroup, timeout: 1) {
return .timedOut(cleanupConfirmed: true)
}
kill(-ownedGroup, SIGKILL)
return .timedOut(cleanupConfirmed: waitForExit(process, ownedGroup: ownedGroup, timeout: 1))
}
guard process.terminationStatus == 0 else { return .commandFailed(process.terminationStatus) }
guard registrationState(at: config, memoryExecutable: memoryExecutable) == .matching else {
return .verificationFailed
}
return .registered
}

private static func waitForExit(_ process: Process, ownedGroup: pid_t, timeout: TimeInterval) -> Bool {
let deadline = DispatchTime.now() + timeout
repeat {
// Waiting for the direct child alone misses children of CLI wrappers.
let groupGone = kill(-ownedGroup, 0) == -1 && errno == ESRCH
if !process.isRunning && groupGone { return true }
if DispatchTime.now() >= deadline { return false }
usleep(10_000)
} while true
}

static func executableCandidates(home: URL, environment: [String: String]) -> [URL] {
let paths = (environment["PATH"] ?? "").split(separator: ":").map(String.init)
+ [home.appendingPathComponent(".local/bin").path, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
var seen = Set<String>()
return paths.filter { $0.hasPrefix("/") && seen.insert($0).inserted }
.map { URL(fileURLWithPath: $0).appendingPathComponent("claude") }
}

static func configurationURL(home: URL, environment: [String: String]) -> URL {
guard let custom = environment["CLAUDE_CONFIG_DIR"], !custom.isEmpty else {
return home.appendingPathComponent(".claude.json")
}
let path = custom.hasPrefix("~/") ? home.appendingPathComponent(String(custom.dropFirst(2))).path : custom
return URL(fileURLWithPath: path, relativeTo: home).standardizedFileURL.appendingPathComponent(".claude.json")
}

private enum RegistrationState { case missing, matching, other, invalid }

private static func registrationState(at url: URL, memoryExecutable: URL) -> RegistrationState {
guard FileManager.default.fileExists(atPath: url.path) else { return .missing }
guard let data = try? Data(contentsOf: url),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return .invalid }
guard let serversValue = root["mcpServers"] else { return .missing }
guard let servers = serversValue as? [String: Any] else { return .invalid }
guard let value = servers["memory"] else { return .missing }
guard let server = value as? [String: Any],
server["command"] as? String == memoryExecutable.path,
server["type"] == nil || server["type"] as? String == "stdio",
server["args"] == nil || (server["args"] as? [String]) == []
else { return .other }
return .matching
}

private static func isExecutable(_ url: URL) -> Bool {
var directory: ObjCBool = false
return FileManager.default.fileExists(atPath: url.path, isDirectory: &directory)
&& !directory.boolValue && FileManager.default.isExecutableFile(atPath: url.path)
}
}
Loading
Loading