Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -134,6 +134,45 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
public var currentSnapshot: (@MainActor () -> BoardModel?)?
|
||||
|
||||
/// **The reload pipeline settling** — `BoardStore.awaitQuiescence()`, and `nil` on a storeless
|
||||
/// committer.
|
||||
///
|
||||
/// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes
|
||||
/// the *next* walk a walk that started after this flush's changes were on disk.
|
||||
@ObservationIgnored
|
||||
public var awaitReloadQuiescence: (@MainActor () async -> Void)?
|
||||
|
||||
/// **Which generation the board `currentSnapshot` answers with is at** —
|
||||
/// `BoardStore.snapshotGeneration`, incremented by every landed reload.
|
||||
///
|
||||
/// `nil` — the closure absent, or answering `nil` because the store has gone — means there is no
|
||||
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
|
||||
/// committer.
|
||||
@ObservationIgnored
|
||||
public var snapshotGeneration: (@MainActor () -> Int?)?
|
||||
|
||||
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot
|
||||
/// it already has.
|
||||
///
|
||||
/// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the
|
||||
/// flush awaits its covering snapshot and does not say what happens if that reload never lands. It
|
||||
/// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by
|
||||
/// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket
|
||||
/// reload … even if not one filesystem event was seen"), so the wait is short and certain in every
|
||||
/// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths,
|
||||
/// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries
|
||||
/// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is
|
||||
/// composed from the snapshot in hand — one stale subject in a degraded configuration, against a
|
||||
/// hang.
|
||||
@ObservationIgnored
|
||||
public var coveringSnapshotDeadline: Duration = .seconds(1)
|
||||
|
||||
/// How often the wait re-reads the generation. Polled rather than signalled for
|
||||
/// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and
|
||||
/// a continuation resumed by a reload that never lands has no way to.
|
||||
@ObservationIgnored
|
||||
public var coveringSnapshotPollInterval: Duration = .milliseconds(10)
|
||||
|
||||
/// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but
|
||||
/// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried
|
||||
/// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`.
|
||||
@@ -208,6 +247,23 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
private var holdsForeignChanges = false
|
||||
|
||||
/// **Whether an app write has closed with no reload landed since** — the covering await's entry
|
||||
/// gate (`awaitCoveringSnapshot()`).
|
||||
///
|
||||
/// Set at every write-bracket close and cleared by every landing, so it answers exactly "is
|
||||
/// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board
|
||||
/// would wait out the whole deadline for a reload nothing has any reason to schedule.
|
||||
///
|
||||
/// **The one corner it does not cover, recorded rather than discovered**: a reload that was
|
||||
/// already *in flight* when the write bracket closed walked the pre-write tree, and its landing
|
||||
/// clears this flag all the same — the store's landing signal carries no such distinction
|
||||
/// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk
|
||||
/// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write
|
||||
/// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and
|
||||
/// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running).
|
||||
@ObservationIgnored
|
||||
private var holdsUncoveredWrites = false
|
||||
|
||||
/// Open **card-window sessions**, each answering with the folder to stage around *right now*.
|
||||
///
|
||||
/// A closure per session rather than a stored URL, because a card can move lane, or into the
|
||||
@@ -222,6 +278,10 @@ public final class GitAutoCommitter {
|
||||
@ObservationIgnored
|
||||
private var isFlushing = false
|
||||
|
||||
/// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`.
|
||||
@ObservationIgnored
|
||||
private var flushWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
init(boardRoot: URL, ledger: EchoLedger) {
|
||||
@@ -259,6 +319,9 @@ public final class GitAutoCommitter {
|
||||
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
|
||||
public func noteWriteBracketClosed() {
|
||||
harvest()
|
||||
// The snapshot the composer diffs is now known to be behind the tree until a reload lands —
|
||||
// see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`.
|
||||
holdsUncoveredWrites = true
|
||||
arm()
|
||||
}
|
||||
|
||||
@@ -269,6 +332,7 @@ public final class GitAutoCommitter {
|
||||
/// *file* at flush time, because this is one bit about a whole reload.
|
||||
public func noteReloadLanded(sawForeignChange: Bool) {
|
||||
if sawForeignChange { holdsForeignChanges = true }
|
||||
holdsUncoveredWrites = false
|
||||
arm()
|
||||
}
|
||||
|
||||
@@ -307,7 +371,7 @@ public final class GitAutoCommitter {
|
||||
public func noteWillWrite() {
|
||||
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
|
||||
isFlushing = true
|
||||
defer { isFlushing = false }
|
||||
defer { endFlushing() }
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
|
||||
@@ -398,9 +462,110 @@ public final class GitAutoCommitter {
|
||||
/// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the
|
||||
/// sessions have ended, so nothing is staged around any more.
|
||||
public func flushNow() async {
|
||||
await awaitCoveringSnapshot()
|
||||
// **Queued behind an in-flight flush, never skipped** — see `awaitFlushInFlight()`.
|
||||
await awaitFlushInFlight()
|
||||
await flush()
|
||||
}
|
||||
|
||||
/// **Suspends until no flush is running** — what makes `flushNow()` a promise rather than an
|
||||
/// attempt (06-history-undo.md ▸ Rules ▸ Auto-commit: "nothing settled is ever left unsaved or
|
||||
/// uncommitted by closing").
|
||||
///
|
||||
/// ### The bug this exists for
|
||||
///
|
||||
/// `flush()` skips when one is already running, which is exactly right for the **debounce** — a
|
||||
/// timer firing into a commit already in progress has nothing to add, and coalescing is the
|
||||
/// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close
|
||||
/// flush, the quit flush, the branch switch's pre-checkout flush and File ▸ Duplicate's: those
|
||||
/// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is
|
||||
/// empty, and a `return` gave them that answer while it was still full.
|
||||
///
|
||||
/// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The
|
||||
/// close sequence releases each session's stage-around and then nudges the committer
|
||||
/// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its
|
||||
/// card-drain deadline before reaching `committerFlush`. With the two intervals both at two
|
||||
/// seconds the debounce fired *into* the drain's last moments about half the time — and the flush
|
||||
/// it started had, in the worst case, planned its commit while the session's folder was still
|
||||
/// staged around. So the in-flight flush committed nothing of the session, the close flush skipped
|
||||
/// behind it, and teardown stopped the committer: the window's whole session was left uncommitted,
|
||||
/// permanently, with no later flush anywhere that could have picked it up. Even in the benign
|
||||
/// interleaving `closeBoard` returned — and at quit, `applicationShouldTerminate` replied — while
|
||||
/// the commit was still detached work in flight.
|
||||
///
|
||||
/// ### The shape
|
||||
///
|
||||
/// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its
|
||||
/// reason: this type is `@MainActor`, so there is no data race to exclude — only a *suspension* to
|
||||
/// wait out — and the thing a caller wants is "tell me when it is over", which is what a resumed
|
||||
/// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed
|
||||
/// another on its way out cannot slip between the resume and the caller's own attempt.
|
||||
private func awaitFlushInFlight() async {
|
||||
while isFlushing {
|
||||
await withCheckedContinuation { flushWaiters.append($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends one flush and releases whoever was queued behind it. The single exit for both flushing
|
||||
/// paths — the debounced one and the synchronous flush-before-overwrite — so a waiter can never be
|
||||
/// left suspended by a path that forgot it.
|
||||
private func endFlushing() {
|
||||
isFlushing = false
|
||||
let waiters = flushWaiters
|
||||
flushWaiters.removeAll()
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
|
||||
/// **The flush awaits the snapshot that covers it** (06-history-undo.md ▸ Rules ▸ Auto-commit,
|
||||
/// ruled 2026-07-31).
|
||||
///
|
||||
/// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot
|
||||
/// > generation covering its changed paths before the committer runs — the commit's subject can
|
||||
/// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the
|
||||
/// > practical cushion, never the guarantee."
|
||||
///
|
||||
/// ### What "covering its changed paths" means to this store
|
||||
///
|
||||
/// A reload is a **whole tree walk** — the store has no changed-path channel at all
|
||||
/// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) — so a walk that *started*
|
||||
/// after this flush's writes were on disk covers every path they touched, by construction. There
|
||||
/// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the
|
||||
/// generation counter a sufficient answer rather than an approximation of one.
|
||||
///
|
||||
/// Two steps, in this order, are what turn it into a guarantee:
|
||||
///
|
||||
/// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing
|
||||
/// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is
|
||||
/// owed, which is the moment after which every walk is a walk that started later.
|
||||
/// 2. **Wait for one generation.** The write bracket that produced these changes already
|
||||
/// scheduled the reload that will supply it — unconditionally, whether or not FSEvents said
|
||||
/// anything (`FolderWatcher.endBracket`) — so this is a bounded wait on work already in the
|
||||
/// pipeline, not a hope.
|
||||
///
|
||||
/// ### Why only the explicit flush
|
||||
///
|
||||
/// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush,
|
||||
/// File ▸ Duplicate's pending-work step, and the undo restore's. Those are the flushes that run
|
||||
/// *because* something just finished, which is exactly when the snapshot can still be one walk
|
||||
/// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds
|
||||
/// after the later of them — 06's own "practical cushion", doing the job it is enough for — and
|
||||
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
|
||||
private func awaitCoveringSnapshot() async {
|
||||
guard holdsUncoveredWrites, let read = snapshotGeneration else { return }
|
||||
await awaitReloadQuiescence?()
|
||||
// Re-read the gate: the quiescence may itself have been the covering landing.
|
||||
guard holdsUncoveredWrites, let base = read() else { return }
|
||||
|
||||
let started = ContinuousClock.now
|
||||
while let current = read(), current == base {
|
||||
guard ContinuousClock.now - started < coveringSnapshotDeadline else {
|
||||
Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(for: coveringSnapshotPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and
|
||||
/// typing churn" is one timer rather than a rule each call site remembers.
|
||||
private func arm(after interval: Duration? = nil) {
|
||||
@@ -414,10 +579,13 @@ public final class GitAutoCommitter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One flush. **Skipping when one is already running is the debounce's rule and only the
|
||||
/// debounce's** — an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`)
|
||||
/// before it gets here, so this guard can only ever coalesce a timer.
|
||||
private func flush() async {
|
||||
guard !isFlushing else { return }
|
||||
isFlushing = true
|
||||
defer { isFlushing = false }
|
||||
defer { endFlushing() }
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
|
||||
@@ -512,6 +680,7 @@ public final class GitAutoCommitter {
|
||||
var previous: BoardModel?
|
||||
var current: BoardModel?
|
||||
var agentGuideText: String?
|
||||
var commentTimestamps: [String: Date] = [:]
|
||||
}
|
||||
|
||||
/// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept
|
||||
@@ -537,6 +706,12 @@ public final class GitAutoCommitter {
|
||||
// card's title. So a comment-only window loads the current board and skips the materialization.
|
||||
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
|
||||
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
|
||||
// **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the
|
||||
// one field of a comment the composer needs and the board snapshot cannot carry. Read beside
|
||||
// the guide's bytes, for the guide's reason, and only for a window that names a comment at all.
|
||||
if namesACard {
|
||||
composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot)
|
||||
}
|
||||
guard touchesModel || namesACard else { return composition }
|
||||
|
||||
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
|
||||
@@ -550,6 +725,42 @@ public final class GitAutoCommitter {
|
||||
return composition
|
||||
}
|
||||
|
||||
/// **When each comment this window touched was created**, keyed by its folder — the chronology
|
||||
/// `CommitMessageEngine` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed
|
||||
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
|
||||
///
|
||||
/// One `index.md` per touched comment folder, read off the **working tree** — which is the state
|
||||
/// this commit is about to stage, and the only place a comment's own fields exist at all. A folder
|
||||
/// this window *removed* (the close purge) has nothing left to read, and a comment whose
|
||||
/// frontmatter does not parse or carries no `created` answers nothing either: all three are
|
||||
/// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own
|
||||
/// fallback for the same field. Nothing here is a defect and nothing is reported — a commit
|
||||
/// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept).
|
||||
///
|
||||
/// Internal rather than private so the composer's own suite can resolve the chronology exactly the
|
||||
/// way a flush does, instead of hand-assembling a map the flush could never produce
|
||||
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
|
||||
nonisolated static func commentTimestamps(
|
||||
for changed: [GitChangedPath],
|
||||
boardRoot: URL
|
||||
) -> [String: Date] {
|
||||
var timestamps: [String: Date] = [:]
|
||||
var seen: Set<String> = []
|
||||
for path in changed {
|
||||
guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted
|
||||
else { continue }
|
||||
let index = boardRoot
|
||||
.appendingPathComponent(folder)
|
||||
.appendingPathComponent(IntegrityRules.indexFileName)
|
||||
guard let data = try? Data(contentsOf: index),
|
||||
let document = try? BoardLoader.parseDocument(data, path: folder),
|
||||
let created = document.created.value
|
||||
else { continue }
|
||||
timestamps[folder] = created
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
|
||||
/// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes.
|
||||
private nonisolated static func plan(
|
||||
_ changed: [GitChangedPath],
|
||||
@@ -571,7 +782,8 @@ public final class GitAutoCommitter {
|
||||
isRootCommit: isRootCommit,
|
||||
snapshot: composition.current,
|
||||
previousSnapshot: composition.previous,
|
||||
agentGuideText: composition.agentGuideText
|
||||
agentGuideText: composition.agentGuideText,
|
||||
commentTimestamps: composition.commentTimestamps
|
||||
)
|
||||
}
|
||||
|
||||
@@ -600,16 +812,23 @@ public final class GitAutoCommitter {
|
||||
authorship = .foreign(
|
||||
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
|
||||
)
|
||||
// **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a
|
||||
// heal's paths commit *separately* and says nothing about who they are by. The healer is
|
||||
// the app acting on the user's behalf — its writes are app-mediated, receipt and all — so
|
||||
// authoring them as the user is the honest reading, and authoring them as `Lanework
|
||||
// External` would blame the outside world for the app's own repair.
|
||||
// **A heal is authored `Lanework Integrity <integrity@lanework.invalid>`** (06 ▸ Commit
|
||||
// messages ▸ Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
|
||||
// origin — not the user's gesture, not a foreign writer — and the separation exists for
|
||||
// audit, so the trail filters by author like every origin". This authored heals as the
|
||||
// *user* until that ruling, which left the separate commit filterable only by message
|
||||
// shape — and the shape vocabulary deliberately never says "healed".
|
||||
case .heal: authorship = .heal
|
||||
case .user: authorship = .user
|
||||
}
|
||||
// The committer stays the user throughout — 06's recorded-by convention, which is why
|
||||
// only the author varies here.
|
||||
let author: GitIdentity
|
||||
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
||||
switch authorship {
|
||||
case let .foreign(identity): author = identity
|
||||
case .heal: author = CommitAttribution.integrityIdentity
|
||||
case .user: author = user
|
||||
}
|
||||
let kind: PlannedCommitKind
|
||||
switch group.kind {
|
||||
case .foreign: kind = .foreign
|
||||
|
||||
Reference in New Issue
Block a user