From 880499bd6c5cf8c34308ce66f6fe56ac0f742b52 Mon Sep 17 00:00:00 2001 From: Maples7 Date: Thu, 14 May 2026 23:20:31 +0800 Subject: [PATCH] fix: improve simulator recovery diagnostics --- CHANGELOG.md | 6 + .../Logic/XcodebuildFailureHint.swift | 49 +++ .../Services/DoctorService.swift | 21 +- Sources/vch/Commands/BuildCommands.swift | 22 +- Sources/vch/Commands/DoctorCommand.swift | 14 +- Tests/VchCLISmokeTests/VchCLISmokeTests.swift | 292 +++++++++++++++++- .../Logic/XcodebuildFailureHintTests.swift | 55 ++++ .../Services/DoctorServiceTests.swift | 13 +- docs/commands.md | 9 + docs/cookbook.md | 11 + 10 files changed, 473 insertions(+), 19 deletions(-) create mode 100644 Sources/VibeChardCore/Logic/XcodebuildFailureHint.swift create mode 100644 Tests/VibeChardCoreTests/Logic/XcodebuildFailureHintTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b05a78..0ccf48e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The English README is the source of truth; localized READMEs may lag. ## Unreleased +### Changed +- Clarified `vch doctor --json` stale-prune reporting: new output uses `worktreePruneRan` for the `git worktree prune` sweep, keeps `prunedStaleEntries` as a deprecated compatibility field, and includes an explicit repair hint for stale simulator bindings. + +### Fixed +- Added a recovery hint when `vch build` / `vch test` logs show `SBMainWorkspace Busy` / `Application failed preflight checks`, pointing users to `--erase-clone` or `vch sim erase`. + ## 0.9.0 - 2026-05-14 ### Added diff --git a/Sources/VibeChardCore/Logic/XcodebuildFailureHint.swift b/Sources/VibeChardCore/Logic/XcodebuildFailureHint.swift new file mode 100644 index 0000000..ef554eb --- /dev/null +++ b/Sources/VibeChardCore/Logic/XcodebuildFailureHint.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Hints for recognizable xcodebuild failure modes where vch already +/// has a safer recovery command than making the user inspect the full +/// firehose. Pure string matching keeps the CLI shell thin and lets +/// unit tests pin the wording. +public enum XcodebuildFailureHint { + /// High-level vch command whose xcodebuild log is being inspected. + public enum Command: String, Sendable { + case build + case test + } + + /// Return an actionable recovery hint when `logText` contains the + /// CoreSimulator launch-preflight failure commonly surfaced as + /// `SBMainWorkspace Busy`, or nil when the log does not match. + public static func simulatorPreflightBusyHint( + logText: String, + command: Command, + taskName: String, + device: String? + ) -> String? { + guard isSimulatorPreflightBusy(logText) else { return nil } + + let resetCommand: String + if let device, !device.isEmpty { + resetCommand = "vch sim erase \(shellQuote(taskName)) --device \(shellQuote(device))" + } else { + resetCommand = "vch sim erase \(shellQuote(taskName)) --device " + } + + return """ + hint: xcodebuild reported SBMainWorkspace Busy (\"Application failed preflight checks\"). The per-task simulator clone may be wedged between install/launch attempts. + rerun once with a clean clone: vch \(command.rawValue) \(shellQuote(taskName)) --erase-clone [same flags] + or reset it explicitly: \(resetCommand) + """ + } + + private static func isSimulatorPreflightBusy(_ logText: String) -> Bool { + guard logText.contains("SBMainWorkspace") else { return false } + let lower = logText.lowercased() + return lower.contains("application failed preflight checks") + || lower.contains("reason: busy") + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} \ No newline at end of file diff --git a/Sources/VibeChardCore/Services/DoctorService.swift b/Sources/VibeChardCore/Services/DoctorService.swift index e244c3d..02cae87 100644 --- a/Sources/VibeChardCore/Services/DoctorService.swift +++ b/Sources/VibeChardCore/Services/DoctorService.swift @@ -14,8 +14,9 @@ import Foundation /// deletes these. /// - **Stale simulator bindings** — a task's `state.simulator.cloneUDID` /// doesn't exist in `simctl list` anymore (someone deleted the -/// device out-of-band). `--clean` does NOT auto-fix these — the -/// right answer is `vch repair` or a manual `vch sim clone`. +/// device out-of-band). `--clean` does NOT auto-fix these — doctor +/// reports them with a repair hint, while build/test/sim selection +/// prunes stale records before reusing or recreating a binding. /// - **State.json problems** — same set as `vch repair` would surface. public struct DoctorService: Sendable { public let workspace: Workspace @@ -63,6 +64,15 @@ public struct DoctorService: Sendable { } public struct Report: Equatable, Sendable { + /// True when `git worktree prune` was invoked. Git does not + /// report whether it actually removed stale admin entries, so + /// this deliberately records the action rather than claiming a + /// persistent mutation occurred. + public var worktreePruneRan: Bool + /// Deprecated JSON compatibility field. Older vch versions set + /// this to true after running `git worktree prune`, but that + /// name implied a mutation we could not prove. New callers + /// should read `worktreePruneRan` instead. public var prunedStaleEntries: Bool public var checkedTasks: [String] public var stateProblems: [String] @@ -77,6 +87,7 @@ public struct DoctorService: Sendable { public var warmTemplates: [WarmTemplateRecord] public init( + worktreePruneRan: Bool = false, prunedStaleEntries: Bool = false, checkedTasks: [String] = [], stateProblems: [String] = [], @@ -84,6 +95,7 @@ public struct DoctorService: Sendable { staleBindings: [StaleBinding] = [], warmTemplates: [WarmTemplateRecord] = [] ) { + self.worktreePruneRan = worktreePruneRan self.prunedStaleEntries = prunedStaleEntries self.checkedTasks = checkedTasks self.stateProblems = stateProblems @@ -105,13 +117,16 @@ public struct DoctorService: Sendable { } } + public static let staleBindingRepairHint = + "stale simulator bindings are reported only; doctor does not mutate task state. Recreate with `vch sim clone --device ...`, or run the next `vch build` / `vch test` with --device so vch can prune and rebind." + /// Read-only sweep. Never deletes anything. public func diagnose() throws -> Report { var report = Report() // Worktree-level checks (delegates to TaskService.repair() shape). try git.worktreePrune(repoCwd: workspace.mainWorktreePath) - report.prunedStaleEntries = true + report.worktreePruneRan = true // Walk live tasks; collect bound UDIDs and surface state problems. var boundByUDID: [String: StaleBinding] = [:] diff --git a/Sources/vch/Commands/BuildCommands.swift b/Sources/vch/Commands/BuildCommands.swift index 2c93a60..134643c 100644 --- a/Sources/vch/Commands/BuildCommands.swift +++ b/Sources/vch/Commands/BuildCommands.swift @@ -140,13 +140,14 @@ private enum BuildOrTest { } let result: PlanLauncher.RunResult + let logURL: URL switch action { case .build: // #48: Build now mirrors the test path — concise summary // by default, full firehose only with --verbose. The full // log is always tee'd to /.vch/last-build.log so // `vch logs --build` can recover it. - let logURL = URL(fileURLWithPath: workspace.lastBuildLogPath(for: task)) + logURL = URL(fileURLWithPath: workspace.lastBuildLogPath(for: task)) CLIBridge.eprintln("→ building\(formatRuntime(resolved?.runtime)) — log: \(logURL.path)") let s = BuildOutputSummarizer() result = try PlanLauncher.runTee( @@ -164,7 +165,7 @@ private enum BuildOrTest { // Test goes through the tee path so we can summarize at // the end (#9). The full log is always preserved at // /.vch/last-test.log regardless of --verbose. - let logURL = URL(fileURLWithPath: workspace.lastTestLogPath(for: task)) + logURL = URL(fileURLWithPath: workspace.lastTestLogPath(for: task)) CLIBridge.eprintln("→ running tests\(formatRuntime(resolved?.runtime)) — log: \(logURL.path)") let s = TestOutputSummarizer() result = try PlanLauncher.runTee( @@ -206,6 +207,23 @@ private enum BuildOrTest { } } + if result.exitCode != 0, resolved != nil, + let logText = try? String(contentsOf: logURL, encoding: .utf8) { + let hintCommand: XcodebuildFailureHint.Command + switch action { + case .build: hintCommand = .build + case .test: hintCommand = .test + } + if let hint = XcodebuildFailureHint.simulatorPreflightBusyHint( + logText: logText, + command: hintCommand, + taskName: task.raw, + device: device + ) { + CLIBridge.eprintln(hint) + } + } + // Always write the outcome — the user wants to know about // failed runs too. We swallow only the inner write error so a // stale state.json doesn't mask the real (build) failure. diff --git a/Sources/vch/Commands/DoctorCommand.swift b/Sources/vch/Commands/DoctorCommand.swift index 279890d..313405c 100644 --- a/Sources/vch/Commands/DoctorCommand.swift +++ b/Sources/vch/Commands/DoctorCommand.swift @@ -79,8 +79,8 @@ struct DoctorCommand: ParsableCommand { clean: DoctorService.CleanReport?, didClean: Bool ) { - if report.prunedStaleEntries { - print("pruned stale worktree entries") + if report.worktreePruneRan { + print("ran git worktree prune") } let checked = report.checkedTasks.isEmpty ? "(none)" : report.checkedTasks.joined(separator: ", ") @@ -108,7 +108,7 @@ struct DoctorCommand: ParsableCommand { for s in report.staleBindings { CLIBridge.eprintln(" - \(s.taskName) → \(s.cloneName) (\(s.cloneUDID))") } - CLIBridge.eprintln(" (run `vch sim clone --device …` or `vch repair`)") + CLIBridge.eprintln(" (\(DoctorService.staleBindingRepairHint))") } if !report.warmTemplates.isEmpty { @@ -148,11 +148,16 @@ struct DoctorCommand: ParsableCommand { let failedDeletes: [String] } struct Out: Encodable { + let worktreePruneRan: Bool + /// Deprecated compatibility field. Kept false because + /// doctor no longer claims `git worktree prune` mutated + /// anything; read `worktreePruneRan` instead. let prunedStaleEntries: Bool let checkedTasks: [String] let stateProblems: [String] let orphanClones: [OrphanJSON] let staleBindings: [StaleJSON] + let staleBindingRepairHint: String? // Encoded directly via WarmTemplateRecord's Encodable // conformance — sharing the schema with `vch sim // warm-template list --json` and the bug-report tarball. @@ -160,6 +165,7 @@ struct DoctorCommand: ParsableCommand { let cleaned: CleanJSON? } let out = Out( + worktreePruneRan: report.worktreePruneRan, prunedStaleEntries: report.prunedStaleEntries, checkedTasks: report.checkedTasks, stateProblems: report.stateProblems, @@ -171,6 +177,8 @@ struct DoctorCommand: ParsableCommand { StaleJSON(taskName: $0.taskName, cloneUDID: $0.cloneUDID, cloneName: $0.cloneName) }, + staleBindingRepairHint: report.staleBindings.isEmpty + ? nil : DoctorService.staleBindingRepairHint, warmTemplates: report.warmTemplates, cleaned: clean.map { c in CleanJSON( diff --git a/Tests/VchCLISmokeTests/VchCLISmokeTests.swift b/Tests/VchCLISmokeTests/VchCLISmokeTests.swift index 14ce1a4..fa15543 100644 --- a/Tests/VchCLISmokeTests/VchCLISmokeTests.swift +++ b/Tests/VchCLISmokeTests/VchCLISmokeTests.swift @@ -65,6 +65,14 @@ final class VchCLISmokeTests: XCTestCase { let gitEnv: [String: String] } + private struct ManagedTaskFixture { + let rootDir: URL + let repoPath: String + let taskPath: String + let taskName: String + let gitEnv: [String: String] + } + private func runVch(_ args: [String]) throws -> Result { let proc = Process() proc.executableURL = URL(fileURLWithPath: try Self.vchPath()) @@ -424,6 +432,122 @@ final class VchCLISmokeTests: XCTestCase { ) } + // MARK: - xcodebuild failure recovery hint integration + + func testTestPreflightBusyFailurePrintsRecoveryHintOnStderr() throws { + try XCTSkipIf( + !FileManager.default.isExecutableFile(atPath: "/usr/bin/git"), + "/usr/bin/git not available" + ) + + let runtime = "com.apple.CoreSimulator.SimRuntime.iOS-26-5" + let fixture = try makeManagedTaskFixture( + taskName: "alpha", + simulator: .init( + cloneUDID: "SIM-1", + sourceUDID: "SRC-1", + name: "iPhone 16-vch-alpha", + templateName: "iPhone 16", + runtimeIdentifier: runtime + ) + ) + defer { try? FileManager.default.removeItem(at: fixture.rootDir) } + + let devicesJSON = """ + {"devices":{"\(runtime)":[{"udid":"SIM-1","name":"iPhone 16-vch-alpha",\ + "state":"Shutdown","isAvailable":true}]}} + """ + let toolEnv = try installFakeToolchain( + rootDir: fixture.rootDir, + devicesJSON: devicesJSON, + xcodebuildStderr: """ + Failed to install or launch the test runner. + Simulator device failed to launch com.maples7.BeanLedger. + The request was denied by service delegate (SBMainWorkspace) for reason: Busy ("Application failed preflight checks"). + """, + xcodebuildExitCode: 65 + ) + let env = fixture.gitEnv.merging(toolEnv) { _, new in new } + + let result = try runVch( + ["test", "alpha", "--scheme", "App", "--device", "iPhone 16"], + cwd: fixture.repoPath, + env: env + ) + + XCTAssertEqual(result.exitCode, 65, "stderr: \(result.stderr)") + XCTAssertTrue( + result.stderr.contains("hint: xcodebuild reported SBMainWorkspace Busy"), + "expected recovery hint on stderr; got: \(result.stderr)" + ) + XCTAssertTrue(result.stderr.contains("vch test 'alpha' --erase-clone [same flags]")) + XCTAssertTrue(result.stderr.contains("vch sim erase 'alpha' --device 'iPhone 16'")) + XCTAssertFalse( + result.stdout.contains("hint: xcodebuild reported SBMainWorkspace Busy"), + "recovery hint must go to stderr, not stdout; stdout: \(result.stdout)" + ) + } + + // MARK: - doctor JSON integration + + func testDoctorJSONReportsWorktreePruneAndStaleBindingHint() throws { + try XCTSkipIf( + !FileManager.default.isExecutableFile(atPath: "/usr/bin/git"), + "/usr/bin/git not available" + ) + + let fixture = try makeManagedTaskFixture( + taskName: "alpha", + simulator: .init( + cloneUDID: "SIM-GONE", + sourceUDID: "SRC-1", + name: "iPhone 16-vch-alpha", + templateName: "iPhone 16", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-5" + ) + ) + defer { try? FileManager.default.removeItem(at: fixture.rootDir) } + let toolEnv = try installFakeToolchain( + rootDir: fixture.rootDir, + devicesJSON: #"{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-26-5":[]}}"#, + xcodebuildStderr: "", + xcodebuildExitCode: 0 + ) + let env = fixture.gitEnv.merging(toolEnv) { _, new in new } + + let result = try runVch( + ["doctor", "--json"], + cwd: fixture.repoPath, + env: env + ) + + XCTAssertEqual(result.exitCode, ExitCode.business, "stderr: \(result.stderr)") + struct DoctorJSON: Decodable { + struct StaleBinding: Decodable { + let taskName: String + let cloneUDID: String + let cloneName: String + } + let worktreePruneRan: Bool + let prunedStaleEntries: Bool + let staleBindings: [StaleBinding] + let staleBindingRepairHint: String? + } + let decoded = try JSONDecoder().decode( + DoctorJSON.self, + from: Data(result.stdout.utf8) + ) + XCTAssertTrue(decoded.worktreePruneRan) + XCTAssertFalse(decoded.prunedStaleEntries) + XCTAssertEqual(decoded.staleBindings.count, 1) + XCTAssertEqual(decoded.staleBindings[0].taskName, "alpha") + XCTAssertEqual(decoded.staleBindings[0].cloneUDID, "SIM-GONE") + XCTAssertEqual(decoded.staleBindings[0].cloneName, "iPhone 16-vch-alpha") + XCTAssertTrue( + decoded.staleBindingRepairHint?.contains("vch sim clone --device") ?? false + ) + } + // MARK: - Agent runbook discovery func testRunbookPrintsVersionPinnedReference() throws { @@ -694,12 +818,12 @@ final class VchCLISmokeTests: XCTestCase { // MARK: - smoke-test git helpers - private func makeAdoptCurrentFixture( - worktreeLeaf: String, - branch: String - ) throws -> AdoptCurrentFixture { + private func makeManagedTaskFixture( + taskName: String, + simulator: TaskState.SimulatorRecord? + ) throws -> ManagedTaskFixture { let rootDir = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("vch-adopt-smoke-\(UUID().uuidString)") + .appendingPathComponent("vch-managed-smoke-\(UUID().uuidString)") try FileManager.default.createDirectory( at: rootDir, withIntermediateDirectories: true ) @@ -709,16 +833,137 @@ final class VchCLISmokeTests: XCTestCase { try? FileManager.default.removeItem(at: rootDir) } } + let repoPath = rootDir.appendingPathComponent("Repo").path - let sidecarPath = rootDir.appendingPathComponent(worktreeLeaf).path + let taskPath = rootDir.appendingPathComponent("Repo-\(taskName)").path try FileManager.default.createDirectory( atPath: repoPath, withIntermediateDirectories: true ) + let gitEnv = hermeticGitEnv(rootDir: rootDir) + try runGit(["init", "-q", "-b", "main"], cwd: repoPath, env: gitEnv) + try runGit(["config", "commit.gpgsign", "false"], cwd: repoPath, env: gitEnv) + try "hello\n".write( + toFile: "\(repoPath)/README.md", + atomically: true, encoding: .utf8 + ) + try runGit(["add", "README.md"], cwd: repoPath, env: gitEnv) + try runGit(["commit", "-q", "-m", "initial"], cwd: repoPath, env: gitEnv) + try runGit( + ["worktree", "add", "-b", "agent/\(taskName)", taskPath], + cwd: repoPath, env: gitEnv + ) - // Stay strictly inside `rootDir`: HOME, XDG_CONFIG_HOME, and - // GIT_CONFIG_NOSYSTEM together keep the test from picking up - // the developer's real git config (signing, hooks, …). - let gitEnv: [String: String] = [ + try FileManager.default.createDirectory( + atPath: "\(taskPath)/.vch", + withIntermediateDirectories: true + ) + var state = TaskState( + name: taskName, + branch: "agent/\(taskName)", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + baseRef: "deadbee", + baseBranch: "main" + ) + if let simulator { + state.setSimulators([simulator]) + } + try state.jsonData().write( + to: URL(fileURLWithPath: "\(taskPath)/.vch/state.json") + ) + + keepRootDir = true + return ManagedTaskFixture( + rootDir: rootDir, + repoPath: repoPath, + taskPath: taskPath, + taskName: taskName, + gitEnv: gitEnv + ) + } + + private func installFakeToolchain( + rootDir: URL, + devicesJSON: String, + xcodebuildStderr: String, + xcodebuildExitCode: Int32 + ) throws -> [String: String] { + let fakeRoot = rootDir.appendingPathComponent("FakeXcode") + let developerDir = fakeRoot.appendingPathComponent("Contents/Developer") + let developerBin = developerDir.appendingPathComponent("usr/bin") + let pathBin = rootDir.appendingPathComponent("fake-bin") + try FileManager.default.createDirectory( + at: developerBin, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: pathBin, + withIntermediateDirectories: true + ) + + let devicesPath = rootDir.appendingPathComponent("simctl-devices.json") + try devicesJSON.write(to: devicesPath, atomically: true, encoding: .utf8) + + try writeExecutable( + developerBin.appendingPathComponent("xcrun"), + body: """ + #!/bin/sh + tool="$1" + shift + if [ "$tool" = "git" ]; then + unset DEVELOPER_DIR + exec /usr/bin/git "$@" + fi + exec "$DEVELOPER_DIR/usr/bin/$tool" "$@" + """ + ) + try writeExecutable( + developerBin.appendingPathComponent("simctl"), + body: """ + #!/bin/sh + if [ "$1" = "list" ] && [ "$2" = "devices" ]; then + cat "$VCH_SMOKE_SIMCTL_DEVICES_JSON" + exit 0 + fi + if [ "$1" = "bootstatus" ] || [ "$1" = "shutdown" ] || [ "$1" = "erase" ] || [ "$1" = "delete" ]; then + exit 0 + fi + echo "unexpected simctl invocation: $*" >&2 + exit 1 + """ + ) + let xcodebuildBody = """ + #!/bin/sh + cat <<'EOF' >&2 + \(xcodebuildStderr) + EOF + exit \(xcodebuildExitCode) + """ + try writeExecutable( + developerBin.appendingPathComponent("xcodebuild"), + body: xcodebuildBody + ) + try writeExecutable( + pathBin.appendingPathComponent("xcodebuild"), + body: xcodebuildBody + ) + + return [ + "DEVELOPER_DIR": developerDir.path, + "PATH": "\(pathBin.path):/usr/bin:/bin", + "VCH_SMOKE_SIMCTL_DEVICES_JSON": devicesPath.path, + ] + } + + private func writeExecutable(_ url: URL, body: String) throws { + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: url.path + ) + } + + private func hermeticGitEnv(rootDir: URL) -> [String: String] { + [ "PATH": "/usr/bin:/bin", "HOME": rootDir.path, "XDG_CONFIG_HOME": rootDir.appendingPathComponent("xdg").path, @@ -728,6 +973,33 @@ final class VchCLISmokeTests: XCTestCase { "GIT_COMMITTER_NAME": "vch test", "GIT_COMMITTER_EMAIL": "vch@test.local", ] + } + + private func makeAdoptCurrentFixture( + worktreeLeaf: String, + branch: String + ) throws -> AdoptCurrentFixture { + let rootDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("vch-adopt-smoke-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: rootDir, withIntermediateDirectories: true + ) + var keepRootDir = false + defer { + if !keepRootDir { + try? FileManager.default.removeItem(at: rootDir) + } + } + let repoPath = rootDir.appendingPathComponent("Repo").path + let sidecarPath = rootDir.appendingPathComponent(worktreeLeaf).path + try FileManager.default.createDirectory( + atPath: repoPath, withIntermediateDirectories: true + ) + + // Stay strictly inside `rootDir`: HOME, XDG_CONFIG_HOME, and + // GIT_CONFIG_NOSYSTEM together keep the test from picking up + // the developer's real git config (signing, hooks, …). + let gitEnv = hermeticGitEnv(rootDir: rootDir) try runGit(["init", "-q", "-b", "main"], cwd: repoPath, env: gitEnv) try runGit(["config", "commit.gpgsign", "false"], cwd: repoPath, env: gitEnv) try "hello\n".write( diff --git a/Tests/VibeChardCoreTests/Logic/XcodebuildFailureHintTests.swift b/Tests/VibeChardCoreTests/Logic/XcodebuildFailureHintTests.swift new file mode 100644 index 0000000..6140469 --- /dev/null +++ b/Tests/VibeChardCoreTests/Logic/XcodebuildFailureHintTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import VibeChardCore + +final class XcodebuildFailureHintTests: XCTestCase { + func testSimulatorPreflightBusyHintFires() { + let log = """ + Failed to install or launch the test runner. + Simulator device failed to launch com.maples7.BeanLedger. + The request was denied by service delegate (SBMainWorkspace) for reason: Busy ("Application failed preflight checks"). + """ + + let hint = XcodebuildFailureHint.simulatorPreflightBusyHint( + logText: log, + command: .test, + taskName: "watch-sync-robustness", + device: "BeanLedger Test iPhone Template 20260514 Fresh" + ) + + XCTAssertNotNil(hint) + XCTAssertTrue(hint!.contains("SBMainWorkspace Busy")) + XCTAssertTrue(hint!.contains("vch test 'watch-sync-robustness' --erase-clone [same flags]")) + XCTAssertTrue(hint!.contains("vch sim erase 'watch-sync-robustness' --device 'BeanLedger Test iPhone Template 20260514 Fresh'")) + } + + func testSimulatorPreflightBusyHintRequiresWorkspaceMarker() { + let log = """ + The request was denied for reason: Busy ("Application failed preflight checks"). + """ + + XCTAssertNil( + XcodebuildFailureHint.simulatorPreflightBusyHint( + logText: log, + command: .test, + taskName: "alpha", + device: "iPhone 16" + ) + ) + } + + func testSimulatorPreflightBusyHintQuotesDeviceNames() { + let log = """ + The request was denied by service delegate (SBMainWorkspace) for reason: Busy. + """ + + let hint = XcodebuildFailureHint.simulatorPreflightBusyHint( + logText: log, + command: .build, + taskName: "alpha", + device: "QA's iPhone" + ) + + XCTAssertNotNil(hint) + XCTAssertTrue(hint!.contains("vch sim erase 'alpha' --device 'QA'\\''s iPhone'")) + } +} \ No newline at end of file diff --git a/Tests/VibeChardCoreTests/Services/DoctorServiceTests.swift b/Tests/VibeChardCoreTests/Services/DoctorServiceTests.swift index 76a018f..f0fb304 100644 --- a/Tests/VibeChardCoreTests/Services/DoctorServiceTests.swift +++ b/Tests/VibeChardCoreTests/Services/DoctorServiceTests.swift @@ -77,7 +77,8 @@ final class DoctorServiceTests: XCTestCase { let report = try svc.diagnose() XCTAssertEqual(git.pruneCalls, 1) - XCTAssertTrue(report.prunedStaleEntries) + XCTAssertTrue(report.worktreePruneRan) + XCTAssertFalse(report.prunedStaleEntries) XCTAssertEqual(report.checkedTasks, ["alpha"]) XCTAssertTrue(report.stateProblems.isEmpty) XCTAssertTrue(report.orphanClones.isEmpty) @@ -170,6 +171,16 @@ final class DoctorServiceTests: XCTestCase { // MARK: - stale bindings + func testDiagnoseDoesNotClaimWorktreePruneMutatedState() throws { + let (svc, _, git, _) = makeService() + + let report = try svc.diagnose() + + XCTAssertEqual(git.pruneCalls, 1) + XCTAssertTrue(report.worktreePruneRan) + XCTAssertFalse(report.prunedStaleEntries) + } + func testDiagnoseDetectsStaleBindingWhenSimGone() throws { // `alpha` points at C-GONE but simctl no longer lists it. let (svc, _, _, _) = makeService( diff --git a/docs/commands.md b/docs/commands.md index 9cd1902..c15293e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -411,6 +411,15 @@ vch doctor --bug-report [--out ] [--json] Detect orphan simulator clones, stale state bindings, and corrupt `state.json`s. Exits non-zero on any finding. +`vch doctor` runs `git worktree prune` as part of the sweep. JSON +output reports this as `worktreePruneRan`; the legacy +`prunedStaleEntries` field is retained for compatibility but no +longer claims a mutation occurred. Stale simulator bindings are +reported only: doctor does not edit task state for missing clone +UDIDs. Recreate the binding with `vch sim clone --device ...`, +or run the next `vch build` / `vch test` with `--device` so vch can +prune and rebind during simulator selection. + `--bug-report` bundles a redacted local diagnostics tarball: every task's `state.json` + `last-test.log`, the porcelain worktree list, and `sw_vers` / `xcode-select -p` / `xcrun -f xcodebuild` / diff --git a/docs/cookbook.md b/docs/cookbook.md index 6ea54a6..47208ef 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -243,6 +243,17 @@ rejects booted devices), so it also collapses any leftover boot state. Costs ~10–20 s — off by default to keep the fast path fast. Drop the flag once the test passes; daily runs don't need it. +The same reset is the first recovery step for xcodebuild launch +failures like: + +``` +SBMainWorkspace Busy ("Application failed preflight checks") +``` + +When `vch build` / `vch test` recognizes that failure in the log, it +prints a hint to rerun once with `--erase-clone` or reset the clone +explicitly with `vch sim erase --device