Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7de92c5
feat(history): carry an unpersisted deletion across the restart that …
send Aug 2, 2026
6bff379
docs(history): fold the deletion marker into the settled record
send Aug 2, 2026
0093a21
docs(history): record what the marker write costs on the key thread
send Aug 2, 2026
6952f5b
refactor(history): make the tmp-path convention one definition
send Aug 2, 2026
1beb849
fix(history): make the marker's retractions answer to durability, not…
send Aug 2, 2026
83da874
test(history): pin the invariants the first round of tests only claimed
send Aug 2, 2026
e143863
test(history): pin the cross-crate hand-off of a replayed unflushed d…
send Aug 2, 2026
0e46f8d
docs(history): measure the write the code actually performs
send Aug 2, 2026
18b5a4c
fix(history): give each retraction an authority it actually has
send Aug 2, 2026
aa99cf1
test(history): make the over-long-marker case discriminating
send Aug 2, 2026
773911f
fix(imkit): let the user acknowledge the lost-deletion report, not th…
send Aug 2, 2026
5d16e4b
fix(history): PR320 Codex R1 — 2 findings resolved
send Aug 2, 2026
aa3af70
fix(history): PR320 Codex R2 — 1 finding resolved
send Aug 2, 2026
810b5da
fix(history): PR320 mid-loop design re-gate — one authority for the s…
send Aug 2, 2026
05b9890
fix(history): PR320 Codex R3 — 4 findings resolved, as two roots
send Aug 2, 2026
cb2a64d
test(history): make the malformed-marker cases discriminating
send Aug 2, 2026
87b99ae
fix(history): PR320 Codex R3 followup + R4 — 3 findings resolved
send Aug 2, 2026
fd6042d
refactor(history): PR320 Codex R5 — collapse the marker into the proj…
send Aug 2, 2026
5003d84
fix(history): re-gate the collapse — two regressions it introduced
send Aug 2, 2026
3189f27
docs(history): disclose which of the projection's guards tests cannot…
send Aug 2, 2026
bcbf084
fix(history): PR320 Codex R6 — back to the canonical write, plus a lo…
send Aug 2, 2026
faa13cf
fix(history): PR320 Codex R7 — 2 findings resolved
send Aug 2, 2026
55ed797
test(history): pin the order that distinguishes the removal's ` &` fr…
send Aug 2, 2026
b379f46
fix(history): PR320 Codex R8 — refute Lost with both halves, retry th…
send Aug 2, 2026
05e2452
fix(history): PR320 Codex R9 — guard write_atomic's tmp; SPEC matches…
send Aug 2, 2026
c31a699
fix(history): PR320 Codex R10 — close the last marker site outside th…
send Aug 2, 2026
760922a
fix(build): libc is needed on every unix, not only Apple
send Aug 2, 2026
451919f
fix(history): PR320 design re-gate — two roots behind the loop's residue
send Aug 2, 2026
7d77d92
fix(history): PR320 Codex R11 — 3 findings, one of them mine from the…
send Aug 2, 2026
4446c84
refactor(history): PR320 R12 — the commit reconciles the marker, not …
send Aug 2, 2026
0a4474e
fix(history): PR320 R13 — stop compensating for a precondition we bre…
send Aug 2, 2026
0fa61e6
fix(history): PR320 R14 — a three-valued fact needs a three-valued type
send Aug 2, 2026
0aafe3d
fix(history): PR320 R15 — refuse sequence exhaustion where numbers ar…
send Aug 2, 2026
10bf1fc
docs(history): drop the freeze explanation R15 replaced
send Aug 2, 2026
16d0c1e
fix(history): PR320 R17 — a CI-only flake I wrote, plus two stale exp…
send Aug 2, 2026
ba40087
fix(history): PR320 R18 — the belief records what landed, not what wa…
send Aug 2, 2026
34aa718
fix(history): PR320 R19 — the seq floor never worked; promotion is ma…
send Aug 2, 2026
cb91e78
fix(history): PR320 R20 — a flushed orphan is a landed claim
send Aug 2, 2026
222f0fe
fix(history): PR320 R21 — the writer reports the stage; the caller ne…
send Aug 2, 2026
f519a71
fix(history): drop the import R21 left unused
send Aug 2, 2026
9dd3312
docs(history): sweep both superseded rules, not just the flagged lines
send Aug 2, 2026
2736c53
docs(history): the disk never retracts the row; two user actions do
send Aug 2, 2026
ee6f8ec
fix(history): PR320 R24 — the marker feeders must not run over a reta…
send Aug 2, 2026
135eec7
fix(history): PR320 R25 — veto the compaction, not each feeder that w…
send Aug 2, 2026
db2de24
fix(history): PR320 R26 — refuse the compaction where it writes, not …
send Aug 2, 2026
d39d7db
fix(history): PR320 Codex R27 — the compaction veto is a fact about t…
send Aug 2, 2026
3b4f03f
fix(history): PR320 Codex R30 — the writer reports name durability on…
send Aug 7, 2026
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
233 changes: 218 additions & 15 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"⚠️ 学習履歴の一部を復旧できませんでした(学習は継続中)" = "⚠️ Some learning history could not be recovered (learning continues)";
"⚠️ 削除した学習内容を保存できませんでした(削除が取り消される可能性があります)" = "⚠️ A deleted entry could not be saved (the deletion may be undone)";
"⚠️ 新しい学習内容を保存できていません(再起動すると失われます)" = "⚠️ Recent learning is not being saved (it will be lost on restart)";
"⚠️ 前回のセッションの削除が保存されていません(削除した内容が復元されている可能性があります。確認して再度削除してください)" = "⚠️ A deletion from a previous session was not saved (the deleted entry may be back; please check and delete it again)";

/* Settings Window */
"Lexime 設定" = "Lexime Settings";
Expand Down
1 change: 1 addition & 0 deletions Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"⚠️ 学習履歴の一部を復旧できませんでした(学習は継続中)" = "⚠️ 学習履歴の一部を復旧できませんでした(学習は継続中)";
"⚠️ 削除した学習内容を保存できませんでした(削除が取り消される可能性があります)" = "⚠️ 削除した学習内容を保存できませんでした(削除が取り消される可能性があります)";
"⚠️ 新しい学習内容を保存できていません(再起動すると失われます)" = "⚠️ 新しい学習内容を保存できていません(再起動すると失われます)";
"⚠️ 前回のセッションの削除が保存されていません(削除した内容が復元されている可能性があります。確認して再度削除してください)" = "⚠️ 前回のセッションの削除が保存されていません(削除した内容が復元されている可能性があります。確認して再度削除してください)";

/* Settings Window */
"Lexime 設定" = "Lexime 設定";
Expand Down
25 changes: 22 additions & 3 deletions SPEC.md

Large diffs are not rendered by default.

59 changes: 54 additions & 5 deletions Sources/Controller/DegradedStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,65 @@ import Foundation
/// the point:
///
/// - `EngineInitFailure` latches. It records what went wrong while the engine
/// was starting, and nothing during the session retracts it.
/// was starting, and nothing the *disk* does during the session retracts it.
/// One row has an exception, and it is a user action rather than a recovery:
/// `.historyDeletionLost` is retired by acknowledging it or by a wipe (see
/// below).
/// - `LexHistoryDurabilityIssue` is polled and clearable. A frozen WAL thaws
/// when a compaction restores appendable form; an unpersisted deletion is
/// covered by the next durable checkpoint. Folding these into `initFailures`
/// would make a recovered disk keep warning forever.
///
/// The dividing line is retraction, not where the fact came from:
/// `.historyDeletionLost` is a durability failure too, but it is a *past* one,
/// so no amount of the disk recovering retracts it and it latches like the rest
/// of startup. Two things retire it, and neither is the disk healing:
///
/// - **a full wipe**, which makes the claim false rather than stale — there is
/// no longer an entry the deletion could have failed against;
/// - **the user acknowledging it**, which leaves the claim true but delivered.
Comment thread
send marked this conversation as resolved.
///
/// Both reach `EngineControlService.retractRowIfSettled()`, which asks the
/// engine whether the report is still owed rather than inferring it from which
/// action ran — so a wipe that fails before its commit point, or an
/// acknowledgement whose unlink fails, keeps the row.
///
/// The other half of that separation is that a runtime issue must show even
/// when startup was clean — the main #295 scenario is a healthy launch
/// followed by a disk that fails hours later. So `menu()` gates on "are there
/// rows", never on whether the engine is degraded; gating on the latter would
/// display nothing in exactly the case this exists for.
enum DegradedStatus {

/// Titles for the disabled status rows, init failures first.
/// Empty when there is nothing to report.
/// One status row.
struct Row: Equatable {
let title: String
/// Whether clicking the row acknowledges a durable record behind it.
///
/// True for exactly one row. Every other row is derived — it either
/// re-derives from live state on each menu open, or latches in memory
/// and dies with the process — so there is nothing for a click to
/// settle. The lost-deletion row is backed by a file that outlives the
/// process, and only a person can say they have seen it: IMKit calls
/// `menu()` on its own, without displaying anything, so "the menu was
/// built" is not evidence of delivery. Verified on-device — the record
/// was consumed four seconds after a relaunch nobody touched.
let acknowledgeable: Bool
}

/// Status rows, init failures first. Empty when there is nothing to report.
static func rows(
initFailures: [EngineInitFailure],
runtimeIssues: [LexHistoryDurabilityIssue]
) -> [String] {
initFailures.map(title(for:)) + runtimeIssues.map(title(for:))
) -> [Row] {
initFailures.map { failure in
Row(title: title(for: failure), acknowledgeable: isAcknowledgeable(failure))
} + runtimeIssues.map { Row(title: title(for: $0), acknowledgeable: false) }
}

static func isAcknowledgeable(_ failure: EngineInitFailure) -> Bool {
if case .historyDeletionLost = failure { return true }
return false
}

static func title(for failure: EngineInitFailure) -> String {
Expand Down Expand Up @@ -54,6 +93,16 @@ enum DegradedStatus {
return NSLocalizedString(
"⚠️ 学習履歴の一部を復旧できませんでした(学習は継続中)",
comment: "Degraded status: user history partially lost, learning continues")
case .historyDeletionLost:
// Says 「前回」 and tells the user what to do, where the runtime row
// below says only that a save is failing right now. Without that
// split the two read as near-duplicates, and on a disk that is
// still failing they appear together — the steady state, not a
// corner. This is the one that has already happened: the entry is
// back, and only the user can finish the job.
return NSLocalizedString(
"⚠️ 前回のセッションの削除が保存されていません(削除した内容が復元されている可能性があります。確認して再度削除してください)",
comment: "Degraded status: a deletion from a previous session never reached disk")
case .customSettings:
return NSLocalizedString(
"⚠️ 設定ファイルの読み込みに失敗(デフォルト設定で動作中)",
Expand Down
96 changes: 89 additions & 7 deletions Sources/EngineContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,49 @@ final class EngineContainer {
initFailures.append(failure)
}

/// The history failures a recovery report produces, as a pure function of
/// the two independent facts it can carry.
///
/// Extracted so the one property that matters here is testable: a lost
/// deletion and a quarantine are independent facts about the same startup,
/// so the lost-deletion case must not live inside the mutually exclusive
/// branch chain that reports the quarantine — routing it through there
/// would let `dataLossSuspected` mask it. Asserting that over
/// `DegradedStatus.rows` cannot work: that function maps whatever list it
/// is handed, so it would only be testing `Array.map`.
static func historyFailures(
deletionLost: Bool,
dataLossSuspected: Bool,
detail: String,
deletionDetail: String
) -> [EngineInitFailure] {
var failures: [EngineInitFailure] = []
if deletionLost {
failures.append(.historyDeletionLost(detail: deletionDetail))
}
if dataLossSuspected {
failures.append(.historyDataLoss(detail: detail))
}
return failures
}

/// Drop the lost-deletion row, whether because a wipe made the claim false
/// or because the user acknowledged it. The only latched row with a
/// retraction, and both of its retractions are user actions.
///
/// Both callers reach it through `EngineControlService.retractRowIfSettled`,
/// which gates on the engine's own `deletionReportOwed()` rather than on
/// control flow — so this stays a pure render-cache edit with no policy in
/// it. (A `historyWasCleared()` wrapper used to sit in front of the wipe
/// path; it never acquired a caller once the service took over, and the
/// test that named it was pinning the wrapper instead of the gate.)
func retractDeletionLostRow() {
initFailures.removeAll {
if case .historyDeletionLost = $0 { return true }
return false
}
}

init(
engine: LexEngine?,
dictionary: LexDictionary?,
Expand Down Expand Up @@ -118,31 +161,70 @@ final class EngineContainer {
// return first. (migrationFailed cannot co-occur with
// migratedFromV1, being its negation; it is appended uniformly
// rather than special-cased.)
// Built lazily: interpolating a UniFFI enum goes through Swift's
// reflection runtime, and on a clean launch this would be the first
// such call in the process — paying its one-time warmup in front of
// the IME becoming responsive, for a string nothing then uses.
func stateDetail() -> String {
"checkpoint: \(report.checkpointState), wal: \(report.walState)"
}
var degraded = ""
if report.migrationFailed {
degraded += " migration=failed(v1 kept)"
}
if report.appendsFrozen {
degraded += " appends=frozen(memory-only until compaction)"
}
// Appended outside the branch chain below, not inside it: the
// chain is mutually exclusive, and a lost deletion co-occurs
// freely with a quarantine (independent facts about the same
// startup). Routing it through the chain would hide it behind
// dataLossSuspected — which is also why it is a case of its own
// and not folded into .historyDataLoss: that one means "past
// learning was lost", this one means the opposite, data that
// survived a deletion the user asked for (#312).
var deletionDetail = ""
if report.deletionLost {
degraded += " deletion=lost(prior session, entry may be back)"
deletionDetail = stateDetail() + degraded
NSLog(
"Lexime: A deletion from a previous session was not persisted (%@)",
deletionDetail)
}
var quarantineDetail = ""
if report.dataLossSuspected {
var detail =
"checkpoint: \(report.checkpointState), wal: \(report.walState)"
quarantineDetail = stateDetail()
if !report.quarantinedPaths.isEmpty {
detail += ", quarantined: \(report.quarantinedPaths.joined(separator: ", "))"
quarantineDetail +=
", quarantined: \(report.quarantinedPaths.joined(separator: ", "))"
}
detail += degraded
NSLog("Lexime: User history recovered with data loss (%@)", detail)
failures.append(.historyDataLoss(detail: detail))
quarantineDetail += degraded
NSLog("Lexime: User history recovered with data loss (%@)", quarantineDetail)
} else if report.migratedFromV1 {
NSLog(
"Lexime: User history migrated from v1 (\(report.framesReplayed) frames)\(degraded)"
)
} else if !report.clean {
} else if !report.clean && !report.deletionLost {
// deletionLost logged its own line above, carrying the same
// `degraded` fragment — without this clause a lost deletion on
// an otherwise healthy start says it twice.
NSLog(
"Lexime: User history recovery events: checkpoint=\(report.checkpointState) wal=\(report.walState)\(degraded)"
)
}
failures.append(
contentsOf: EngineContainer.historyFailures(
deletionLost: report.deletionLost,
dataLossSuspected: report.dataLossSuspected,
detail: quarantineDetail,
deletionDetail: deletionDetail))
// No ack here. `bootstrap()` runs on every process launch,
// including the short-lived IMKit probe launches this controller
// already designs around — none of which ever render a menu. Acking
// at load would consume the report on the user's behalf and put
// #295's gap back one layer up. Nor does `menu()` ack: IMKit
// builds the menu without displaying it, so construction is not
// delivery either. The row's click handler is what acknowledges.
history = h
} catch {
NSLog("Lexime: Failed to open user history at %@: %@", historyPath, "\(error)")
Expand Down
14 changes: 14 additions & 0 deletions Sources/EngineInitFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ enum EngineInitFailure {
/// User history recovery quarantined corrupt data: learning is running,
/// but some past learning was lost (bytes preserved in `.corrupt-*`).
case historyDataLoss(detail: String)
/// A deletion requested in an earlier session never reached disk, so the
/// history loaded at startup may still hold the entry it was meant to
/// remove (#312). The inverse of `.historyDataLoss`: here data survived
/// that should not have.
///
/// An init failure rather than a `LexHistoryDurabilityIssue` on lifetime.
/// Runtime issues are polled because the *disk* retracts them — a frozen
/// WAL thaws, an unpersisted deletion is covered by the next checkpoint.
/// No amount of the disk recovering retracts this one: the deletion is
/// already lost, and only the user deleting again resolves it. Two user
/// actions do retire the row — acknowledging it, and wiping the history,
/// which makes the claim false rather than stale — and both go through
/// `EngineControlService.retractRowIfSettled()`.
case historyDeletionLost(detail: String)
/// Custom settings.toml exists but failed to parse (defaults in effect).
case customSettings(detail: String)
}
29 changes: 24 additions & 5 deletions Sources/LeximeInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,23 @@ class LeximeInputController: IMKInputController {
// Re-derived on every open, so a runtime issue that has since healed
// stops being shown. See DegradedStatus for why the polled issues are
// not merged into the container's latched initFailures.
let control = AppContext.shared.makeEngineControlService()
let rows = DegradedStatus.rows(
initFailures: AppContext.shared.engineContainer.initFailures,
runtimeIssues: AppContext.shared.makeEngineControlService()
.historyDurabilityIssues())
runtimeIssues: control.historyDurabilityIssues())
if !rows.isEmpty {
for row in rows {
// No action/target: IMKit renders these as disabled status rows.
let item = NSMenuItem(title: row, action: nil, keyEquivalent: "")
item.isEnabled = false
// Status rows are disabled — except the one whose durable
// record a click retires. Building the menu is not delivery:
// IMKit calls this method on its own, so acknowledging here
// would consume the #312 report on a launch that displayed
// nothing (measured: four seconds after an untouched relaunch).
let item = NSMenuItem(
title: row.title,
action: row.acknowledgeable ? #selector(acknowledgeDeletionReport) : nil,
keyEquivalent: "")
item.target = row.acknowledgeable ? self : nil
item.isEnabled = row.acknowledgeable
menu.addItem(item)
}
menu.addItem(.separator())
Expand All @@ -212,6 +220,17 @@ class LeximeInputController: IMKInputController {
return menu
}

/// The user clicked the lost-deletion row, which is the only evidence
/// this process can have that the report reached a person.
///
/// The row's fate is the engine's to decide — it declines to retire the
/// record while a compaction holds the wal mutex, or while this session
/// has an unpersisted deletion of its own, and in both cases the marker
/// stays and the row must too.
@objc private func acknowledgeDeletionReport() {
AppContext.shared.makeEngineControlService().acknowledgeHistoryReport()
}

@objc private func showSettings() {
SettingsWindowController.shared.showWindow()
}
Expand Down
Loading
Loading