From eca130d7e83999145034fa3d2ec4ed4e27c0c7dd Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:10:02 +0200 Subject: [PATCH 01/12] ci: build and test on the xcode-27 runner --- .github/workflows/build.yml | 26 ++++++++++++++------------ .github/workflows/release-dmg.yml | 9 +-------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c88941f..dfa729b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,14 @@ concurrency: jobs: build: - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 60 + # Debug signs with the maintainer's team (project.yml), which a runner + # doesn't have; the test bundles build ad-hoc instead. + env: + SIGNING_OVERRIDE: CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM= + steps: - name: Checkout uses: actions/checkout@v4 @@ -23,17 +28,6 @@ jobs: - name: Install XcodeGen run: brew install xcodegen - # Flotilla.icon is an Icon Composer bundle (specular/refractivity - # features); actool on GitHub's Xcode 26.6 runners crashes compiling it - # (works fine locally on Xcode 27). Move it out of the way — both - # targets that reference it mark that source `optional: true` — and - # point the macOS app at the plain FlotillaFallback.appiconset instead, - # so CI validates a real build without needing Xcode 27. - - name: Use fallback app icon for CI - run: | - mv Flotilla.icon Flotilla.icon.disabled-for-ci - sed -i '' 's/ASSETCATALOG_COMPILER_APPICON_NAME: Flotilla$/ASSETCATALOG_COMPILER_APPICON_NAME: FlotillaFallback/' project.yml - - name: Generate Xcode project run: xcodegen generate @@ -43,3 +37,11 @@ jobs: # Release only: it's what ships, and Xcode compiles Debug on every Run. - name: Build (Release) run: make build-release + + - name: Run app unit tests + run: make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE" + + # Experiment: hosted runners may not grant UI automation Accessibility. + - name: Run UI tests + continue-on-error: true + run: make test-ui SIGNING_OVERRIDE="$SIGNING_OVERRIDE" diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml index 1d0f857..7c87bdf 100644 --- a/.github/workflows/release-dmg.yml +++ b/.github/workflows/release-dmg.yml @@ -20,7 +20,7 @@ jobs: # Secrets live in the protected `release` environment: the job waits for # the maintainer's approval and only runs for v* tags. environment: release - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 45 # The `secrets` context isn't allowed in a step's `if:` condition, so @@ -36,13 +36,6 @@ jobs: - name: Install XcodeGen run: brew install xcodegen - # See build.yml — actool on GitHub's Xcode 26.6 runners crashes on - # Flotilla.icon; use the plain fallback appiconset instead. - - name: Use fallback app icon for CI - run: | - mv Flotilla.icon Flotilla.icon.disabled-for-ci - sed -i '' 's/ASSETCATALOG_COMPILER_APPICON_NAME: Flotilla$/ASSETCATALOG_COMPILER_APPICON_NAME: FlotillaFallback/' project.yml - - name: Generate Xcode project run: xcodegen generate From 67b0f642340a660467d479fdc27cdab5e306cfc4 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:11:35 +0200 Subject: [PATCH 02/12] ci: install the Metal toolchain on the xcode-27 runner --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dfa729b..39af60b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,11 @@ jobs: - name: Install XcodeGen run: brew install xcodegen + # SwiftTerm compiles a Metal shader; the xcode-27 image ships without + # the Metal toolchain component. + - name: Install Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain + - name: Generate Xcode project run: xcodegen generate From 64c6326d51995c7372cfe08fa68c053ded187cc2 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:14:48 +0200 Subject: [PATCH 03/12] ci: install tmux and time out hung unit tests --- .github/workflows/build.yml | 10 +++++++--- Makefile | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39af60b..2353c65 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,8 +25,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install XcodeGen - run: brew install xcodegen + # The real-pipeline tests drive /opt/homebrew/bin/tmux. + - name: Install XcodeGen and tmux + run: brew install xcodegen tmux # SwiftTerm compiles a Metal shader; the xcode-27 image ships without # the Metal toolchain component. @@ -44,7 +45,10 @@ jobs: run: make build-release - name: Run app unit tests - run: make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE" + # A hung test fails after 2 minutes instead of eating the job timeout. + run: > + make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE" + TEST_FLAGS="-test-timeouts-enabled YES -maximum-test-execution-time-allowance 120" # Experiment: hosted runners may not grant UI automation Accessibility. - name: Run UI tests diff --git a/Makefile b/Makefile index e300e7e..b08de47 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ SCENARIO ?= # override signing on the command line, e.g.: # make build SIGNING_OVERRIDE="CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM=" SIGNING_OVERRIDE ?= +# Extra xcodebuild flags for `make test`, e.g. CI's per-test timeout. +TEST_FLAGS ?= # A local development team signs Ephemeral and the installed Release build # with a stable identity so TCC grants (Documents access, Handy approval) # survive rebuilds. Without the ignored local signing @@ -85,6 +87,7 @@ test: xcodegen -parallel-testing-enabled YES \ -maximum-parallel-testing-workers 4 \ $(SIGNING_OVERRIDE) \ + $(TEST_FLAGS) \ test -only-testing:FlotillaUnitTests # The two local packages with standalone SwiftPM test targets. Unlike the app From 7711deb0037c538a20a8bd724e9d849ed788918d Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:26:38 +0200 Subject: [PATCH 04/12] test(hooks): point OpenCode writer tests at a temp global plugins dir --- FlotillaUnitTests/HooksKitTests.swift | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/FlotillaUnitTests/HooksKitTests.swift b/FlotillaUnitTests/HooksKitTests.swift index 6e91d41..bf54770 100644 --- a/FlotillaUnitTests/HooksKitTests.swift +++ b/FlotillaUnitTests/HooksKitTests.swift @@ -1079,6 +1079,15 @@ final class HookConfigurationWriterTests: XCTestCase { } } + /// Never the real `~/.config/opencode/plugins`. + private var openCodePluginsDirectory: URL { + workingDirectory.deletingLastPathComponent().appendingPathComponent("opencode-plugins", isDirectory: true) + } + + private var openCodeWriter: HookConfigurationWriter { + HookConfigurationWriter(openCodeGlobalPluginsDirectory: openCodePluginsDirectory) + } + /// Never the real `~/.gemini/config/hooks.json`. private var antigravityHooksFile: URL { workingDirectory.deletingLastPathComponent().appendingPathComponent("gemini-config/hooks.json") @@ -1353,7 +1362,7 @@ final class HookConfigurationWriterTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: configFile), existing) } func testOpenCodeRelaunchOverwritesRatherThanAccumulating() throws { - let writer = HookConfigurationWriter() + let writer = openCodeWriter let sessionID = UUID() for _ in 0..<3 { _ = writer.configureHooks( @@ -1364,13 +1373,12 @@ final class HookConfigurationWriterTests: XCTestCase { ) } - let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true) - let entries = try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path) + let entries = try FileManager.default.contentsOfDirectory(atPath: openCodePluginsDirectory.path) XCTAssertEqual(entries.count, 1, "relaunching must overwrite, not accumulate, the stable plugin file") } func testOpenCodeTwoSessionsShareOneEnvironmentRoutedPlugin() throws { - let writer = HookConfigurationWriter() + let writer = openCodeWriter _ = writer.configureHooks( for: .openCode, sessionID: UUID(), @@ -1384,18 +1392,18 @@ final class HookConfigurationWriterTests: XCTestCase { supportDirectory: supportDirectory ) - let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true) - let entries = try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path) + let entries = try FileManager.default.contentsOfDirectory(atPath: openCodePluginsDirectory.path) XCTAssertEqual(entries, ["flotilla-status.js"], "one stable plugin routes each process to its own event file") } func testConcurrentOpenCodeConfigurationKeepsOneStablePlugin() async throws { let workingDirectory = try XCTUnwrap(workingDirectory) let supportDirectory = try XCTUnwrap(supportDirectory) + let pluginsDirectory = openCodePluginsDirectory let results = await withTaskGroup(of: Bool.self, returning: [Bool].self) { group in for _ in 0..<12 { group.addTask { - HookConfigurationWriter().configureHooks( + HookConfigurationWriter(openCodeGlobalPluginsDirectory: pluginsDirectory).configureHooks( for: .openCode, sessionID: UUID(), workingDirectory: workingDirectory, @@ -1408,8 +1416,6 @@ final class HookConfigurationWriterTests: XCTestCase { return values } XCTAssertTrue(results.allSatisfy { $0 }) - - let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true) XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path), ["flotilla-status.js"]) } @@ -1421,7 +1427,7 @@ final class HookConfigurationWriterTests: XCTestCase { try Data().write(to: legacy) try Data().write(to: userPlugin) - XCTAssertTrue(HookConfigurationWriter().configureHooks( + XCTAssertTrue(openCodeWriter.configureHooks( for: .openCode, sessionID: UUID(), workingDirectory: workingDirectory, From 6be7974a63b48e2b9681d2027b2290073743b28a Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:35:21 +0200 Subject: [PATCH 05/12] test(pipeline): fail fast instead of hanging when tmux can't launch --- FlotillaUnitTests/RealPipelineReproTests.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FlotillaUnitTests/RealPipelineReproTests.swift b/FlotillaUnitTests/RealPipelineReproTests.swift index 7821e4f..1f2cfde 100644 --- a/FlotillaUnitTests/RealPipelineReproTests.swift +++ b/FlotillaUnitTests/RealPipelineReproTests.swift @@ -454,7 +454,9 @@ final class RealPipelineReproTests: XCTestCase { let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice - try? process.run() + // A launch failure leaves our end of the pipe open, so reading to + // EOF would block forever. + guard (try? process.run()) != nil else { return nil } let screen = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) process.waitUntilExit() if screen.contains("Agent exited") { return screen } @@ -560,7 +562,7 @@ final class RealPipelineReproTests: XCTestCase { let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice - try? process.run() + guard (try? process.run()) != nil else { return nil } let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() return String(decoding: data, as: UTF8.self) @@ -573,7 +575,7 @@ final class RealPipelineReproTests: XCTestCase { let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice - try? process.run() + guard (try? process.run()) != nil else { return nil } let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() let value = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) From c00cf532eba4e9dd8b7874230d940b7be09b7ed4 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:04:15 +0200 Subject: [PATCH 06/12] ci: try a 1920x1080 display for UI tests --- .github/workflows/build.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2353c65..1e9d4cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,17 @@ jobs: make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE" TEST_FLAGS="-test-timeouts-enabled YES -maximum-test-execution-time-allowance 120" + # The runner's display is 1024x768, narrower than the main window's + # minimum width, so controls past x=1024 aren't hittable. + - name: Enlarge the display for UI tests + continue-on-error: true + run: | + brew install displayplacer + displayplacer list + id=$(displayplacer list | awk '/^Persistent screen id:/ {print $4; exit}') + displayplacer "id:$id res:1920x1080" + system_profiler SPDisplaysDataType | grep -i resolution + # Experiment: hosted runners may not grant UI automation Accessibility. - name: Run UI tests continue-on-error: true From 1043cffd255e544d4a47cf01c362e2abe58b15cf Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:17:32 +0200 Subject: [PATCH 07/12] ci: run UI tests after unit failures and keep result bundles --- .github/workflows/build.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e9d4cf..03d2301 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,6 +53,7 @@ jobs: # The runner's display is 1024x768, narrower than the main window's # minimum width, so controls past x=1024 aren't hittable. - name: Enlarge the display for UI tests + if: ${{ !cancelled() }} continue-on-error: true run: | brew install displayplacer @@ -63,5 +64,18 @@ jobs: # Experiment: hosted runners may not grant UI automation Accessibility. - name: Run UI tests + if: ${{ !cancelled() }} continue-on-error: true run: make test-ui SIGNING_OVERRIDE="$SIGNING_OVERRIDE" + + # The log only names failing tests; messages, attachments and test-host + # crash reports live in the result bundles. + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + build/DerivedData/Logs/Test/*.xcresult + ~/Library/Logs/DiagnosticReports/Flotilla* + retention-days: 7 From 4d2714a87bb48da995e43d349eedf08011386143 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:38:14 +0200 Subject: [PATCH 08/12] ci: run app unit tests on xcode-27, defer UI tests --- .github/workflows/build.yml | 18 --------------- AGENTS.md | 46 +++++++++++++++---------------------- 2 files changed, 19 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03d2301..d57d180 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,24 +50,6 @@ jobs: make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE" TEST_FLAGS="-test-timeouts-enabled YES -maximum-test-execution-time-allowance 120" - # The runner's display is 1024x768, narrower than the main window's - # minimum width, so controls past x=1024 aren't hittable. - - name: Enlarge the display for UI tests - if: ${{ !cancelled() }} - continue-on-error: true - run: | - brew install displayplacer - displayplacer list - id=$(displayplacer list | awk '/^Persistent screen id:/ {print $4; exit}') - displayplacer "id:$id res:1920x1080" - system_profiler SPDisplaysDataType | grep -i resolution - - # Experiment: hosted runners may not grant UI automation Accessibility. - - name: Run UI tests - if: ${{ !cancelled() }} - continue-on-error: true - run: make test-ui SIGNING_OVERRIDE="$SIGNING_OVERRIDE" - # The log only names failing tests; messages, attachments and test-host # crash reports live in the result bundles. - name: Upload test results diff --git a/AGENTS.md b/AGENTS.md index 934b2aa..3171491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -521,8 +521,17 @@ xcodebuild -project Flotilla.xcodeproj -scheme Flotilla -configuration Debug \ ### CI/CD (GitHub Actions) Pipeline at `.github/workflows/build.yml`: -1. Checkout → use the CI fallback app icon → generate project +1. Checkout → install XcodeGen, tmux and the Metal toolchain → generate project 2. Run standalone `CompanionKit` and `TerminalKit` SwiftPM tests → build Release +3. `make test` (FlotillaUnitTests) with a 2-minute per-test timeout +4. Upload the `.xcresult` bundles and any Flotilla crash reports as `test-results` + +Both workflows run on GitHub's `xcode-27` image (a preview label), so CI and +releases use the same Xcode 27 as local builds and compile the real +`Flotilla.icon`. That image lacks the Metal toolchain SwiftTerm's shader needs, +and tmux, which the real-pipeline tests drive at `/opt/homebrew/bin/tmux`. +The log names failing tests but not their messages; read those from the +`test-results` artifact (`xcrun xcresulttool get test-results tests --path …`). A newer push to the same ref cancels the in-flight run. There is no Debug build (Xcode compiles it on every Run) and no CI archive or artifact; the @@ -534,16 +543,12 @@ Publishing a GitHub Release archives the app with `make archive`, wraps `Flotill (also uploaded as the `Flotilla-DMG` artifact). Notarization is not included yet — Gatekeeper will still warn on first open until that is added. -The GitHub-hosted workflow deliberately does **not** run `make test` or -`make test-ui`. App unit-test compilation hits a Swift 6 region-based -isolation-checker compiler error in GitHub's Xcode 26.6 (`Task.detached` in -`CompanionHostTests`), while the developer's Xcode 27 toolchain compiles it. -UI tests are intentionally local/Xcode-only: macOS UI automation needs -Accessibility permission from the process driving the test (see -"UI Vocabulary Screenshot Pipeline" above), which a hosted runner cannot be -given. Run `make test` and the targeted `make test-ui` locally before merging -changes that affect application behavior or UI/accessibility. `make test-packages` -keeps CI exercising the two local packages that have standalone SwiftPM tests. +CI does not run `make test-ui` yet. UI automation itself works on the hosted +runner, but its display boots at 1024x768, narrower than the main window's +minimum width. Switching it to 1920x1080 (`displayplacer`) still leaves the +window 692 pt tall, and 11 of 30 UI tests fail on content that doesn't fit. +Run the targeted `make test-ui` locally before merging changes that affect +UI/accessibility. Debug signs with the maintainer's own Apple Development team (`DEVELOPMENT_TEAM: UWAHVC4JTL` in `project.yml`) so that Screen Recording @@ -578,22 +583,9 @@ The certificate must belong to team `UWAHVC4JTL`, which the Makefile's `ARCHIVE_SIGNING_OVERRIDE` names. `Flotilla.icon` is an Icon Composer bundle using features (`specular-location`, -`refractivity`) that only Xcode 27 beta's `actool` parses correctly — the -maintainer's local Xcode. GitHub's hosted `macos-26` runners top out at Xcode -26.6 stable, whose `actool` crashes compiling it (`Could not open -"Flotilla.icon"` / `NSPlaceholderArray … nil object`), confirmed deterministic -across reruns. Both jobs' "Use fallback app icon for CI" step works around -this before generating the project: it moves `Flotilla.icon` aside (both -targets that reference it mark that source `optional: true` in `project.yml`, -so `xcodegen` tolerates its absence) and repoints -`ASSETCATALOG_COMPILER_APPICON_NAME` at the plain -`Flotilla/Resources/Assets.xcassets/FlotillaFallback.appiconset` instead — a -traditional multi-size PNG icon with no Icon Composer features, so it compiles -on any Xcode version. This only affects the ephemeral CI checkout; local -builds are untouched. XcodeGen does not support `${VAR:-default}`-style -templating in `project.yml` (it passes the literal string through unresolved), -which is why this is a plain `mv`/`sed` step in the workflow rather than an -env-var-driven source path. +`refractivity`) that Xcode 26.6's `actool` crashes on. CI used to swap in +`FlotillaFallback.appiconset` for that; on the `xcode-27` image it compiles +the real icon, so both workflows build it as-is. ## Design System From e5b1f590640b6d98a2a1f967e04ed36d6e0f59f2 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:51:56 +0200 Subject: [PATCH 09/12] fix(gitkit): serialize MockGitService call recording across concurrent calls --- .../Sources/GitKit/MockGitService.swift | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/Packages/GitKit/Sources/GitKit/MockGitService.swift b/Packages/GitKit/Sources/GitKit/MockGitService.swift index c7543b4..e832d37 100644 --- a/Packages/GitKit/Sources/GitKit/MockGitService.swift +++ b/Packages/GitKit/Sources/GitKit/MockGitService.swift @@ -28,25 +28,25 @@ public final class MockGitService: GitServiceProtocol, @unchecked Sendable { public var mergedBranchesToReturn: Set = [] public var errorToThrow: Error? - public private(set) var createWorktreeCalls: [(basePath: URL, branch: String, destination: URL)] = [] - public private(set) var removeWorktreeCalls: [(path: URL, repoPath: URL, branch: String, deleteBranch: Bool)] = [] - public private(set) var stageCalls: [(paths: [String], repoPath: URL)] = [] - public private(set) var unstageCalls: [(paths: [String], repoPath: URL)] = [] - public private(set) var discardCalls: [(paths: [String], repoPath: URL)] = [] - public private(set) var commitCalls: [(message: String, repoPath: URL)] = [] - public private(set) var pushCalls: [(branch: String, repoPath: URL)] = [] - public private(set) var fetchCalls: [URL] = [] - public private(set) var logCalls: [(repoPath: URL, ref: String?, skip: Int, maxCount: Int)] = [] - public private(set) var logGraphCalls: [(repoPath: URL, maxCount: Int)] = [] - public private(set) var branchesCalls: [URL] = [] - public private(set) var commitDetailCalls: [(sha: String, repoPath: URL)] = [] - public private(set) var commitsOnBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] - public private(set) var comparisonCalls: [(base: String, repoPath: URL)] = [] - public private(set) var uncommittedChangeCalls: [URL] = [] - public private(set) var checkoutCalls: [(branch: String, repoPath: URL)] = [] - public private(set) var createBranchCalls: [(branch: String, repoPath: URL)] = [] - public private(set) var deleteBranchCalls: [(branch: String, force: Bool, repoPath: URL)] = [] - public private(set) var mergedBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] + @Locked public private(set) var createWorktreeCalls: [(basePath: URL, branch: String, destination: URL)] = [] + @Locked public private(set) var removeWorktreeCalls: [(path: URL, repoPath: URL, branch: String, deleteBranch: Bool)] = [] + @Locked public private(set) var stageCalls: [(paths: [String], repoPath: URL)] = [] + @Locked public private(set) var unstageCalls: [(paths: [String], repoPath: URL)] = [] + @Locked public private(set) var discardCalls: [(paths: [String], repoPath: URL)] = [] + @Locked public private(set) var commitCalls: [(message: String, repoPath: URL)] = [] + @Locked public private(set) var pushCalls: [(branch: String, repoPath: URL)] = [] + @Locked public private(set) var fetchCalls: [URL] = [] + @Locked public private(set) var logCalls: [(repoPath: URL, ref: String?, skip: Int, maxCount: Int)] = [] + @Locked public private(set) var logGraphCalls: [(repoPath: URL, maxCount: Int)] = [] + @Locked public private(set) var branchesCalls: [URL] = [] + @Locked public private(set) var commitDetailCalls: [(sha: String, repoPath: URL)] = [] + @Locked public private(set) var commitsOnBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] + @Locked public private(set) var comparisonCalls: [(base: String, repoPath: URL)] = [] + @Locked public private(set) var uncommittedChangeCalls: [URL] = [] + @Locked public private(set) var checkoutCalls: [(branch: String, repoPath: URL)] = [] + @Locked public private(set) var createBranchCalls: [(branch: String, repoPath: URL)] = [] + @Locked public private(set) var deleteBranchCalls: [(branch: String, force: Bool, repoPath: URL)] = [] + @Locked public private(set) var mergedBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] public init() {} @@ -227,7 +227,7 @@ public final class MockGhService: GhServiceProtocol, @unchecked Sendable { public var urlToReturn = URL(string: "https://github.com/example/example/pull/1")! public var errorToThrow: Error? - public private(set) var createPullRequestCalls: [(title: String, body: String, base: String?, repoPath: URL)] = [] + @Locked public private(set) var createPullRequestCalls: [(title: String, body: String, base: String?, repoPath: URL)] = [] public init() {} @@ -240,7 +240,7 @@ public final class MockGhService: GhServiceProtocol, @unchecked Sendable { public var ciStatusByBranch: [String: CIStatus] = [:] public var ciErrorToThrow: Error? public var failedLogToReturn = "" - public private(set) var ciStatusCalls: [String] = [] + @Locked public private(set) var ciStatusCalls: [String] = [] public func ciStatus(forBranch branch: String, at repoPath: URL) async throws -> CIStatus? { ciStatusCalls.append(branch) @@ -270,3 +270,25 @@ public final class MockGhService: GhServiceProtocol, @unchecked Sendable { return issue } } + +/// Serializes a recorded-call array. The mocks' async methods run on the +/// cooperative pool, so overlapping calls (e.g. a view model starting a load +/// per selection change) would otherwise append concurrently and corrupt it. +@propertyWrapper +public final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + public init(wrappedValue: Value) { + value = wrappedValue + } + + public var wrappedValue: Value { + get { lock.withLock { value } } + _modify { + lock.lock() + defer { lock.unlock() } + yield &value + } + } +} From 5e55f454733fbdb9c062556554d98fb2268c87ee Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:54:29 +0200 Subject: [PATCH 10/12] fix(tmux): wait for helper processes via terminationHandler Bounded waits parked a GCD worker in waitUntilExit(), which can return seconds after the process exits. The server probe's 2s bound then tripped on a healthy server (tmux ls exits in <20 ms), reported it wedged, and new sessions launched without tmux. --- .../Session/SessionScreenReader.swift | 10 ++-- Flotilla/Services/TmuxSessionWrapping.swift | 51 ++++++++++--------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/Flotilla/Features/Session/SessionScreenReader.swift b/Flotilla/Features/Session/SessionScreenReader.swift index b8d66dd..98f04d0 100644 --- a/Flotilla/Features/Session/SessionScreenReader.swift +++ b/Flotilla/Features/Session/SessionScreenReader.swift @@ -62,18 +62,14 @@ struct ProcessTmuxPaneCapture: TmuxPaneCapturing { let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { return nil } - let done = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - process.waitUntilExit() - done.signal() - } - guard done.wait(timeout: .now() + 2) == .success else { + guard exited.wait(timeout: .now() + 2) == .success else { process.terminate() return nil } diff --git a/Flotilla/Services/TmuxSessionWrapping.swift b/Flotilla/Services/TmuxSessionWrapping.swift index 28d8ff0..c01c430 100644 --- a/Flotilla/Services/TmuxSessionWrapping.swift +++ b/Flotilla/Services/TmuxSessionWrapping.swift @@ -462,8 +462,9 @@ struct ProcessTmuxServerProbe: TmuxServerProbing { let errorPipe = Pipe() process.standardOutput = FileHandle.nullDevice process.standardError = errorPipe + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { // The probe itself failed to run — that says nothing about the // server, so keep the existing behavior (try tmux). @@ -473,12 +474,7 @@ struct ProcessTmuxServerProbe: TmuxServerProbing { // A wedged server drops the client immediately, but a *hung* one // must not block the caller: bound the wait, and treat a timeout as // "not answering" too. - let done = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - process.waitUntilExit() - done.signal() - } - guard done.wait(timeout: .now() + 2) == .success else { + guard exited.wait(timeout: .now() + 2) == .success else { process.terminate() return false } @@ -517,14 +513,8 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating { // `new-session -A` with the same name, and an in-flight kill landing // after that would kill the freshly created session. Only wait when // the process actually started, with a bounded wait to prevent hangs. - guard (try? process.run()) != nil else { return } - - let done = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - done.signal() - } - if done.wait(timeout: .now() + 2) != .success { + guard let exited = try? process.runSignalingExit() else { return } + if exited.wait(timeout: .now() + 2) != .success { process.terminate() } } @@ -539,8 +529,9 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating { let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { return [] } @@ -549,7 +540,7 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating { var data = Data() DispatchQueue.global(qos: .userInitiated).async { data = outputPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() + exited.wait() done.signal() } guard done.wait(timeout: .now() + 2) == .success, process.terminationStatus == 0 else { @@ -684,18 +675,14 @@ struct ProcessTmuxGoalDeliverer: TmuxGoalDelivering { let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { return nil } - let done = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - process.waitUntilExit() - done.signal() - } - guard done.wait(timeout: .now() + 2) == .success else { + guard exited.wait(timeout: .now() + 2) == .success else { process.terminate() return nil } @@ -729,3 +716,19 @@ private enum TmuxGoalDeliveryError: LocalizedError { } } } + +extension Process { + /// `run()`, returning a semaphore signalled once the process exits. + /// + /// Bounded waits must hang off `terminationHandler`: parking a GCD worker + /// in `waitUntilExit()` instead can return seconds after the process has + /// exited. Measured: `tmux ls` exiting in under 20 ms still overran the + /// server probe's 2-second bound about once in 15 calls, so the probe + /// reported a healthy server as wedged and sessions launched without tmux. + func runSignalingExit() throws -> DispatchSemaphore { + let exited = DispatchSemaphore(value: 0) + terminationHandler = { _ in exited.signal() } + try run() + return exited + } +} From 3f11feea4bfdb59823d86e31832850034bff6810 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:54:30 +0200 Subject: [PATCH 11/12] test(pipeline): say why a tmux pane never appeared --- FlotillaUnitTests/RealPipelineReproTests.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/FlotillaUnitTests/RealPipelineReproTests.swift b/FlotillaUnitTests/RealPipelineReproTests.swift index 1f2cfde..4138e6d 100644 --- a/FlotillaUnitTests/RealPipelineReproTests.swift +++ b/FlotillaUnitTests/RealPipelineReproTests.swift @@ -315,7 +315,10 @@ final class RealPipelineReproTests: XCTestCase { ) let created = try XCTUnwrap(store.sessions.first { $0.title == "Agent exit restart" }) let sessionName = TmuxSessionWrapping.sessionName(for: created.id) - let originalPID = try XCTUnwrap(waitForTmuxPanePID(sessionName)) + let originalPID = try XCTUnwrap( + waitForTmuxPanePID(sessionName), + "no tmux pane; store error: \(store.lastOperationError ?? "none"), sessions: \(tmuxListSessions())" + ) let process = try XCTUnwrap(store.process(for: created.id)) XCTAssertTrue(process.isRunning) @@ -465,6 +468,19 @@ final class RealPipelineReproTests: XCTestCase { return nil } + private func tmuxListSessions() -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux") + process.arguments = TmuxSessionWrapping.socketArguments() + ["ls"] + let output = Pipe() + process.standardOutput = output + process.standardError = output + guard (try? process.run()) != nil else { return "tmux failed to launch" } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return "\(TmuxSessionWrapping.socketArguments()) exit \(process.terminationStatus): \(String(decoding: data, as: UTF8.self))" + } + private func waitForTmuxPanePID(_ name: String, timeout: TimeInterval = 10.0) -> String? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { From 2a2c020005029d7f99c41cace811806a6c13654d Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:07:28 +0200 Subject: [PATCH 12/12] test(worktree): wait for the session's worktree, not the recorded git call --- .../AgentManagedWorktreeAndTitleTests.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift b/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift index ff6191c..d03b316 100644 --- a/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift +++ b/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift @@ -219,10 +219,7 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase { // Wait for the fallback to fire (it creates a stopgap worktree once // the shortened threshold elapses with no descriptor on disk). - for _ in 0..<40 { - if !gitService.createWorktreeCalls.isEmpty { break } - try await Task.sleep(for: .milliseconds(20)) - } + try await waitForWorktree(of: unwrappedID, in: store) XCTAssertEqual(gitService.createWorktreeCalls.count, 1) let stopgapSession = try XCTUnwrap(store.sessions.first(where: { $0.id == unwrappedID })) XCTAssertNotNil(stopgapSession.worktree) @@ -330,10 +327,7 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase { let unwrappedID = try XCTUnwrap(sessionID) // Wait for fallback worktree to be created - for _ in 0..<40 { - if !gitService.createWorktreeCalls.isEmpty { break } - try await Task.sleep(for: .milliseconds(20)) - } + try await waitForWorktree(of: unwrappedID, in: store) let fallbackWorktree = store.sessions.first(where: { $0.id == unwrappedID })?.worktree XCTAssertNotNil(fallbackWorktree, "Fallback worktree should exist") @@ -361,4 +355,14 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase { AgentSelfReportCoordinator.clearDescriptor(for: unwrappedID, supportDirectory: supportDir) } + + /// The mock records `createWorktree` before returning, and the store + /// assigns the worktree only after that await resumes — so wait for the + /// session itself, not the recorded call. + private func waitForWorktree(of sessionID: UUID, in store: AppStore) async throws { + for _ in 0..<100 { + if store.sessions.first(where: { $0.id == sessionID })?.worktree != nil { return } + try await Task.sleep(for: .milliseconds(20)) + } + } }