import Foundation import Testing @testable import Kanban /// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler; /// 01-storage-format.md § Validation and healing, settled 2026-07-29) — the six-step pattern the /// three healers used to re-derive one by one, pinned once. /// /// Each healer's own end-to-end behavior stays where it always lived (`LooseFileRelocationTests`, /// `TrashWriteTests`, `AgentGuideTests`); what is here is the pattern itself, plus the two places the /// generalization *changed* behavior on purpose — the writability gate, which was only the guide's, /// and the explicit clear-on-success, which was only the guide's too. // MARK: - Fixtures /// A board whose one card has a loose file — the shape that gives the engine real work through the /// simplest healer. @MainActor private func makeLooseFileBoard() throws -> (fixture: WriterFixture, cardPath: String) { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) return (fixture, "\(Ident.lane1)/\(Ident.card1)") } @MainActor private final class BracketLog { private(set) var begins = 0 func attach(to store: BoardStore) { store.watcherBrackets = (begin: { self.begins += 1 }, end: {}) } } // MARK: - The gates @MainActor @Suite("HealScheduler ▸ the gates") struct HealSchedulerGateTests { /// **Step 1** — no work is not a failed attempt: an empty signature clears the memo, which is /// what makes a healthy board forget it ever tried. @Test("No work rests, and clears the memo") func noWorkRests() throws { let board = try makeLooseFileBoard() defer { board.fixture.tearDown() } try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: board.fixture.root) store.relocateLooseCardFiles() #expect(store.heals.memo(for: .looseCardFiles) == nil, "cleared on success") // Nothing loose left in the snapshot's own reading — the resting path. store.relocateLooseCardFiles() #expect(store.heals.memo(for: .looseCardFiles) == nil) } /// **Step 2, the lock half** — defer, never abandon, and remember nothing, so the reload that /// lifts the lock is the reload that heals. @Test("A read-only board defers and remembers nothing") func lockDefersWithoutArming() throws { let board = try makeLooseFileBoard() defer { board.fixture.tearDown() } try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: board.fixture.root) let brackets = BracketLog() brackets.attach(to: store) store.enterUnwritableLock(.permissionDenied) store.relocateLooseCardFiles() #expect(brackets.begins == 0) #expect(store.heals.memo(for: .looseCardFiles) == nil, "a refused attempt is not a remembered one") #expect(board.fixture.exists("\(board.cardPath)/notes.txt")) } /// **Step 2, the writability half — generalized 2026-07-29.** This `access(2)` check was the /// agent guide's private defense; it now covers every healer, because it is the only gate that /// covers the window *between* writability probes. Without it a heal on a root that went /// read-only mid-session reaches the Writer, fails, and banners about work the user never asked /// for. @Test("An unwritable board root is skipped silently, for every healer") func unwritableRootIsSkippedSilently() throws { let board = try makeLooseFileBoard() defer { board.fixture.tearDown() } try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: board.fixture.root) let brackets = BracketLog() brackets.attach(to: store) // Read-only root, with no lock standing — the mid-session window the probe cannot see. try FileManager.default.setAttributes( [.posixPermissions: 0o500], ofItemAtPath: board.fixture.root.path ) defer { try? FileManager.default.setAttributes( [.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path ) } store.runScheduledHeals() #expect(brackets.begins == 0, "no write was attempted at all") #expect(store.banners.oneShots.isEmpty, "and nothing was said about it") #expect(store.banners.losses.isEmpty) #expect(store.heals.memo(for: .looseCardFiles) == nil) } /// **Steps 3 and 4** — armed before the attempt, so a failure is remembered; one failure, one /// row, then silence until the picture on disk actually changes. @Test("A failure arms the memo and is not retried") func failureArmsTheMemo() throws { let board = try makeLooseFileBoard() defer { board.fixture.tearDown() } try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: board.fixture.root) let brackets = BracketLog() brackets.attach(to: store) // The card folder is writable but its `attachments/` cannot be created: the relocation's // write half fails, with the defect still on disk afterwards. try FileManager.default.setAttributes( [.posixPermissions: 0o500], ofItemAtPath: board.fixture.url(board.cardPath).path ) defer { try? FileManager.default.setAttributes( [.posixPermissions: 0o755], ofItemAtPath: board.fixture.url(board.cardPath).path ) } store.relocateLooseCardFiles() let armed = store.heals.memo(for: .looseCardFiles) store.relocateLooseCardFiles() #expect(armed != nil, "armed before the attempt, so the throw leaves it set") #expect(store.heals.memo(for: .looseCardFiles) == armed) #expect(brackets.begins == 1, "the second call was refused by the memo, not attempted") #expect(store.banners.oneShots.count == 1, "one failure, one row") } /// **Step 6, generalized 2026-07-29** — the memo clears *explicitly* on success, where before /// only the guide did this and the others merely happened to converge because the next walk /// found no work. It matters when the same defect comes back: a foreign undo restores the exact /// picture the heal just fixed, and a standing memo would make that the one thing the self-heal /// could not heal. @Test("The same picture heals again after a success") func theSamePictureHealsAgain() throws { let board = try makeLooseFileBoard() defer { board.fixture.tearDown() } try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: board.fixture.root) store.relocateLooseCardFiles() #expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8)) #expect(store.heals.memo(for: .looseCardFiles) == nil) // Somebody puts it back, byte for byte — the same defect signature as before. try FileManager.default.removeItem(at: board.fixture.url("\(board.cardPath)/attachments")) try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) store.relocateLooseCardFiles() #expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8)) } /// The memos are per class, so one heal's failure never silences another's work. @Test("Memos are per defect class") func memosArePerClass() throws { let board = try makeLooseFileBoard() defer { board.fixture.tearDown() } try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: board.fixture.root) store.runScheduledHeals() // The guide wrote (its class rests cleared), the relocation ran (so does its own), and // neither class's memo is holding the other's picture. #expect(store.heals.memo(for: .looseCardFiles) == nil) #expect(store.heals.memo(for: .staleAgentGuide) == nil) #expect(store.heals.memo(for: .legacyTombstone) == nil) #expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8)) #expect(board.fixture.exists(AgentGuide.filename)) } } // MARK: - The inline renumber-and-retry @Suite("HealScheduler ▸ the renumber two-step") struct HealSchedulerRenumberTests { /// A ladder with room answers on the **first** ask — no compaction, and the ladder comes back /// exactly as it went in. @Test("A usable ladder never renumbers") func usableLadderNeverRenumbers() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B")) let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") let placed = try #require(try HealScheduler.placingRanks( amongVisible: [1024, 2048], compacting: fixture.url(Ident.lane1), { Ranks.insertionRank(amongVisible: $0, at: 1) } )) #expect(placed.placement == 1536) #expect(placed.ladder == [1024, 2048]) #expect(!placed.renumbered) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == before, "nothing was rewritten") } /// Exhausted precision compacts the parent's visible children and asks again — **against the /// fresh ladder**, which is what the caller then has to place among. @Test("Exhausted precision compacts and answers against the fresh ladder") func exhaustedPrecisionCompacts() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) // Adjacent doubles: no representable midpoint between them. try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1", title: "A")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000000000000002", title: "B")) let orders = try [Ident.card1, Ident.card2].map { try #require(FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order.value) } let placed = try #require(try HealScheduler.placingRanks( amongVisible: orders, compacting: fixture.url(Ident.lane1), { Ranks.insertionRank(amongVisible: $0, at: 1) } )) #expect(placed.renumbered) #expect(placed.ladder == [1024, 2048]) #expect(placed.placement == 1536) // The compaction really landed on disk — the two cards now hold the fresh ladder. let after = try [Ident.card1, Ident.card2].map { try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order } #expect(after == [.valid(1024), .valid(2048)]) } /// A compacted ladder that still has no answer is "write nothing" — every call site's own /// posture, spelled once. @Test("No answer after compacting is nil") func noAnswerAfterCompactingIsNil() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) let placed = try HealScheduler.placingRanks( amongVisible: [], compacting: fixture.url(Ident.lane1), { (_: [Double]) -> Double? in nil } ) #expect(placed == nil) } }