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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions Sources/VibeChardCore/Logic/XcodebuildFailureHint.swift
Original file line number Diff line number Diff line change
@@ -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 <template-name>"
}

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: "'\\''") + "'"
}
}
21 changes: 18 additions & 3 deletions Sources/VibeChardCore/Services/DoctorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -77,13 +87,15 @@ public struct DoctorService: Sendable {
public var warmTemplates: [WarmTemplateRecord]

public init(
worktreePruneRan: Bool = false,
prunedStaleEntries: Bool = false,
checkedTasks: [String] = [],
stateProblems: [String] = [],
orphanClones: [SimDevice] = [],
staleBindings: [StaleBinding] = [],
warmTemplates: [WarmTemplateRecord] = []
) {
self.worktreePruneRan = worktreePruneRan
self.prunedStaleEntries = prunedStaleEntries
self.checkedTasks = checkedTasks
self.stateProblems = stateProblems
Expand All @@ -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 <task> --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] = [:]
Expand Down
22 changes: 20 additions & 2 deletions Sources/vch/Commands/BuildCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wt>/.vch/last-build.log so
// `vch logs <name> --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(
Expand All @@ -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
// <wt>/.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(
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions Sources/vch/Commands/DoctorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ", ")
Expand Down Expand Up @@ -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 <task> --device …` or `vch repair`)")
CLIBridge.eprintln(" (\(DoctorService.staleBindingRepairHint))")
}

if !report.warmTemplates.isEmpty {
Expand Down Expand Up @@ -148,18 +148,24 @@ 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.
let warmTemplates: [WarmTemplateRecord]
let cleaned: CleanJSON?
}
let out = Out(
worktreePruneRan: report.worktreePruneRan,
prunedStaleEntries: report.prunedStaleEntries,
checkedTasks: report.checkedTasks,
stateProblems: report.stateProblems,
Expand All @@ -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(
Expand Down
Loading