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) + } +}