diff --git a/.gitignore b/.gitignore
index 5004610..3637c4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,16 @@ scratch/
# to jest kluczowy kod tego projektu, wiec jawnie go odignorowujemy.
!scripts/
!scripts/*.sh
+
+# Lokalny stan narzedzi Claude / claude-flow / ruflo.
+# To sa artefakty konkretnej maszyny i sesji (liczniki edycji, polityki,
+# przyjeta konfiguracja) - nie opisuja projektu i nie naleza do repozytorium.
+# Definicje agentow (.claude/agents/) i CLAUDE.md zostaja SLEDZONE celowo.
+.claude-flow/
+.claude/proven-config.json
+.claude/.proven-config-version
+.claude/settings.local.json
+.claude/worktrees/
+.claude/sessions/
+.claude/todos/
+.specstory/
diff --git a/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template b/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template
index 97370b2..ca0a2d1 100644
--- a/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template
+++ b/launchd/com.renacode.cloudmachine.gdrive-attach.plist.template
@@ -17,7 +17,9 @@
po tym, jak bufor padl i wrocil.
Kolejnosc wzgledem bufora nie wymaga sterowania z zewnatrz: `attach-image`
- sam czeka do dwoch minut, az montowanie stanie.
+ sam czeka, az bufor stanie - ile i na co, patrz `BufferReadiness`. To
+ czekanie, nie ten interwal, jest glowna obrona przed wyscigiem po starcie:
+ interwal tylko sprzata po sytuacji, w ktorej obraz odpial sie pozniej.
-->
StartInterval
900
diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift
index e7eb61e..e0fd579 100644
--- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift
+++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift
@@ -83,10 +83,24 @@ struct AttachImage: AsyncParsableCommand {
func run() async throws {
// Time Machine nie moze zobaczyc celu, zanim bufor bedzie gotowy - inaczej
- // uzna, ze dysk backupu zniknal.
- for _ in 0..<60 {
- if DriveBufferService.isMounted { break }
- try? await Task.sleep(nanoseconds: 2_000_000_000)
+ // uzna, ze dysk backupu zniknal. Ile czekamy i na co dokladnie - patrz
+ // `BufferReadiness`.
+ let ready = await BufferReadiness.wait(
+ sleep: { seconds in
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
+ },
+ probe: {
+ BufferReadiness.isReady(
+ mounted: DriveBufferService.isMounted,
+ imageVisible: BackupImageService.exists)
+ })
+ if !ready {
+ print(
+ """
+ Bufor nie stanal w \(Int(BufferReadiness.defaultTimeout / 60)) min - nie podpinam obrazu.
+ Time Machine jest teraz BEZ CELU. Sprawdz: cloudmachine-agent drive-status
+ """)
+ throw ExitCode(1)
}
let result = await BackupImageService.attach()
print(result.message)
diff --git a/mac-app/Sources/CloudMachineCore/BufferReadiness.swift b/mac-app/Sources/CloudMachineCore/BufferReadiness.swift
new file mode 100644
index 0000000..bd1a755
--- /dev/null
+++ b/mac-app/Sources/CloudMachineCore/BufferReadiness.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+/// Kiedy bufor jest na tyle gotowy, zeby podpiac na nim obraz.
+///
+/// Wydzielone z `attach-image`, zeby dalo sie sprawdzic testem bez montowania
+/// czegokolwiek - tak samo jak `CooldownGate` i `TimeMachineStatus`.
+public enum BufferReadiness {
+ /// Ile czekac na gotowy bufor, zanim uznamy to za awarie.
+ ///
+ /// Dwie minuty wystarczaly, dopoki bufor startowal pusty. Po nieczystym
+ /// zamknieciu jest inaczej: 13 wrz 2026 rclone wczytywal po starcie 225
+ /// brudnych pozycji (9,4 GB), montowanie stanelo o 07:56:30, a czekanie
+ /// poddalo sie o 07:56:20 - dziesiec sekund za wczesnie. Time Machine
+ /// zostal bez celu az do nastepnego tykniecia launchd, czyli na 15 minut,
+ /// i nikt sie o tym nie dowiedzial poza kodem wyjscia, ktorego nikt nie czyta.
+ ///
+ /// Czekanie jest darmowe - `attach` na juz podpietym obrazie tylko mowi
+ /// "Juz podpiete" - a przegapione okno kosztuje kwadrans bez backupu.
+ public static let defaultTimeout: TimeInterval = 900
+
+ /// Co ile odpytywac.
+ public static let defaultPoll: TimeInterval = 2
+
+ /// Samo montowanie nie wystarczy.
+ ///
+ /// rclone wystawia montowanie, ZANIM wczyta brudny cache, wiec przez chwile
+ /// katalog jest pusty. Podpiecie odpadloby wtedy na "Brak obrazu" - czyli na
+ /// tym samym wyscigu, tyle ze o krok pozniej.
+ public static func isReady(mounted: Bool, imageVisible: Bool) -> Bool {
+ mounted && imageVisible
+ }
+
+ /// Czeka na gotowosc bufora. Zwraca `true`, jesli sie doczekal.
+ ///
+ /// Zegar i uspienie sa wstrzykiwane, zeby test nie musial czekac naprawde.
+ public static func wait(
+ timeout: TimeInterval = defaultTimeout,
+ poll: TimeInterval = defaultPoll,
+ now: () -> Date = Date.init,
+ sleep: (TimeInterval) async -> Void,
+ probe: () -> Bool
+ ) async -> Bool {
+ let deadline = now().addingTimeInterval(timeout)
+ while true {
+ if probe() { return true }
+ if now() >= deadline { return false }
+ await sleep(poll)
+ }
+ }
+}
diff --git a/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift b/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift
new file mode 100644
index 0000000..881d419
--- /dev/null
+++ b/mac-app/Tests/CloudMachineAppTests/BufferReadinessTests.swift
@@ -0,0 +1,101 @@
+import XCTest
+
+@testable import CloudMachineCore
+
+/// Testy czekania na gotowy bufor.
+///
+/// Kazdy odtwarza konkretny wyscig, ktory juz zdarzyl sie na zywo - nie
+/// sprawdzamy, ze funkcja "dziala", tylko ze zachowuje sie inaczej niz wersja,
+/// ktora 13 wrz 2026 zostawila Time Machine bez celu.
+final class BufferReadinessTests: XCTestCase {
+
+ /// Zegar, ktory rusza sie tylko wtedy, gdy kod naprawde by spal. Dzieki temu
+ /// test mierzy CZEKANIE, a nie predkosc maszyny.
+ private final class FakeClock {
+ private(set) var now = Date(timeIntervalSince1970: 1_757_700_000)
+ func sleep(_ seconds: TimeInterval) { now.addTimeInterval(seconds) }
+ }
+
+ // MARK: - Warunek gotowosci
+
+ /// To jest ten drugi wyscig: montowanie juz stoi, ale rclone nie wczytal
+ /// jeszcze brudnego cache, wiec katalog jest pusty. Stara wersja uznawala to
+ /// za gotowosc i podpiecie odpadalo na "Brak obrazu".
+ func testMontowanieBezObrazuToNieGotowosc() {
+ XCTAssertFalse(BufferReadiness.isReady(mounted: true, imageVisible: false))
+ }
+
+ func testObrazBezMontowaniaToNieGotowosc() {
+ XCTAssertFalse(BufferReadiness.isReady(mounted: false, imageVisible: true))
+ }
+
+ func testJednoIDrugieToGotowosc() {
+ XCTAssertTrue(BufferReadiness.isReady(mounted: true, imageVisible: true))
+ }
+
+ // MARK: - Czekanie
+
+ /// ZNANA ZLA PROBKA: bufor staje po 150 s. Stary limit 120 s poddawal sie
+ /// dziesiec sekund za wczesnie i wlasnie to zdarzylo sie 13 wrz 2026.
+ func testDoczekaSieBuforaKtoryStajePo150s() async {
+ let clock = FakeClock()
+ let gotowyOd = clock.now.addingTimeInterval(150)
+
+ let ready = await BufferReadiness.wait(
+ now: { clock.now },
+ sleep: { clock.sleep($0) },
+ probe: { clock.now >= gotowyOd })
+
+ XCTAssertTrue(ready, "Bufor stanal po 150 s - czekanie musi go zlapac")
+ }
+
+ /// Dowod, ze poprzedni test nie przechodzi dlatego, ze funkcja zwraca zawsze
+ /// `true`: bufor, ktory nie staje NIGDY, musi zostac zgloszony jako awaria.
+ func testPoddajeSieGdyBuforNieStajeWcale() async {
+ let clock = FakeClock()
+
+ let ready = await BufferReadiness.wait(
+ now: { clock.now },
+ sleep: { clock.sleep($0) },
+ probe: { false })
+
+ XCTAssertFalse(ready, "Bufor nigdy nie stanal - to musi byc awaria, nie cisza")
+ }
+
+ /// Czekanie ma sie skonczyc mniej wiecej na zadeklarowanym limicie, a nie
+ /// ciagnac w nieskonczonosc: launchd czeka na ten proces.
+ func testKonczyCzekanieNaZadeklarowanymLimicie() async {
+ let clock = FakeClock()
+ let start = clock.now
+
+ _ = await BufferReadiness.wait(
+ now: { clock.now },
+ sleep: { clock.sleep($0) },
+ probe: { false })
+
+ let elapsed = clock.now.timeIntervalSince(start)
+ XCTAssertGreaterThanOrEqual(elapsed, BufferReadiness.defaultTimeout)
+ XCTAssertLessThan(elapsed, BufferReadiness.defaultTimeout + BufferReadiness.defaultPoll * 2)
+ }
+
+ /// Gotowy bufor nie moze kosztowac ani jednego uspienia - `attach-image`
+ /// chodzi tez z reki i po kazdym tyknieciu launchd.
+ func testGotowyBuforNieCzekaWcale() async {
+ let clock = FakeClock()
+ let start = clock.now
+
+ let ready = await BufferReadiness.wait(
+ now: { clock.now },
+ sleep: { clock.sleep($0) },
+ probe: { true })
+
+ XCTAssertTrue(ready)
+ XCTAssertEqual(clock.now, start, "Gotowy bufor ma wracac natychmiast")
+ }
+
+ /// Limit musi byc wiekszy niz zaobserwowane 150 s, inaczej naprawa jest
+ /// pozorna. Zapisane wprost, zeby nikt go nie scial z powrotem do dwoch minut.
+ func testLimitJestWiekszyNizZaobserwowanyWyscig() {
+ XCTAssertGreaterThan(BufferReadiness.defaultTimeout, 150)
+ }
+}