Skip to content

feat(sync): add result history and last-result log#110

Open
ndycode wants to merge 15 commits intodevfrom
fresh/11-sync-history-log
Open

feat(sync): add result history and last-result log#110
ndycode wants to merge 15 commits intodevfrom
fresh/11-sync-history-log

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 16, 2026

Summary

  • Reopens the preserved sync-history slice from archived PR #84
  • Stacked PR on fresh/06-docs-frontdoor-cleanup

What Changed

  • Adds sync result history and the last-result log, including the bounded recovery-read fix that avoids unbounded NDJSON reads.
  • Keeps the branch focused to sync history/result logging and the matching sync/live-account tests.

Validation

  • npm run lint
  • npm run typecheck
  • npm test
  • npm test -- test/documentation.test.ts
  • npm run build

Docs and Governance Checklist

  • README updated (if user-visible behavior changed)
  • docs/getting-started.md updated (if onboarding flow changed)
  • docs/features.md updated (if capability surface changed)
  • relevant docs/reference/* pages updated (if commands/settings/paths changed)
  • docs/upgrade.md updated (if migration behavior changed)
  • SECURITY.md and CONTRIBUTING.md reviewed for alignment

Risk and Rollback

  • Risk level: medium
  • Rollback plan: revert 44bb909 and d57449f

Additional Notes

  • Preserved branch validation from the fresh-restack run passed lint, typecheck, build, and full tests.

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr adds bounded ndjson sync-result history (sync-history.ndjson + sync-history-latest.json) shared by both the codex-cli sync and live-account-sync paths, plus an async cold-start hydration of lastCodexCliSyncRun from that history. the chunked tail-reader avoids full-file reads for bounded lookups, writes are serialized by a promise-chain mutex, and EBUSY/EPERM retries are applied on the write paths for windows compatibility.

  • lib/sync-history.ts — new module: ndjson append, 8 KB chunked tail-reader, 200-entry trim-on-overflow, retryHistoryWrite wrapper, and a sync-history-latest.json fast-read shortcut
  • lib/codex-cli/sync.tspublishCodexCliSyncRun now fires appendSyncHistoryEntry (fire-and-forget with .catch), and loadLastCodexCliSyncRun hydrates lastCodexCliSyncRun on cold start with promise deduplication
  • lib/live-account-sync.tsrecordHistory added to runReload; path is redacted to basename before writing
  • lib/index.ts — re-exports both sync-history and the updated live-account-sync surface
  • readLatestSyncHistory (line 253–268) is the only read path that lacks retryHistoryWrite, making it silently fall back to the more expensive tail-read on a transient windows EBUSY lock
  • the __resetLastCodexCliSyncRunForTests helper nulls lastCodexCliSyncRunLoadPromise without guarding against an in-flight promise's .then() callback overwriting lastCodexCliSyncRunLoadedFromHistory after the reset — a potential source of cross-test state leakage
  • no vitest test covers concurrent loadLastCodexCliSyncRun call deduplication or the hydrateLastCodexCliSyncRunFromHistory guard against clobbering a fresher in-memory run

Confidence Score: 3/5

  • mergeable with follow-up work; two logic issues (missing retry on latest-file read, test-reset stale closure) and a gap in concurrency test coverage should be resolved before relying on history hydration in production
  • the core write path (mutex, retry, trim) is well-implemented and the chunked tail-reader avoids the unbounded read on bounded queries. however, readLatestSyncHistory is missing the same retry guard applied everywhere else — a windows antivirus lock silently forces the more expensive fallback path on every cold start. the __resetLastCodexCliSyncRunForTests stale-closure bug can produce flaky test state. the concurrency paths (promise deduplication, hydrate guard) have no dedicated vitest coverage despite being the most novel and risky additions in the PR.
  • lib/sync-history.ts (missing retry in readLatestSyncHistory) and lib/codex-cli/sync.ts (stale-closure in test reset helper) need the most attention.

Important Files Changed

Filename Overview
lib/sync-history.ts core new module — mutex-chained ndjson append, chunked tail-reader, trim-on-overflow, retry logic for writes; readLatestSyncHistory lacks retry on EBUSY/EPERM unlike every other read path
lib/codex-cli/sync.ts adds async history hydration on cold start and publishCodexCliSyncRun persistence; __resetLastCodexCliSyncRunForTests has a stale-closure bug that can leak lastCodexCliSyncRunLoadedFromHistory across tests
lib/live-account-sync.ts adds recordHistory calls on reload success/error via fire-and-forget void; path is redacted to basename before writing; serialized correctly by withHistoryLock
lib/index.ts adds public re-exports for sync-history.ts and live-account-sync.ts; test helpers (__reset*, __get*) are exported alongside production API, consistent with the existing pattern in this library
test/sync-history.test.ts good coverage of tail-read, ebusy-retry, trim-without-readFile, and history-recovery; missing tests for concurrent loadLastCodexCliSyncRun deduplication and the hydrate-guard race
test/codex-cli-sync.test.ts comprehensive sync reconcile tests; correctly sets up history temp dir in beforeEach and tears it down after; no tests for the promise deduplication path in loadLastCodexCliSyncRun
test/live-account-sync.test.ts solid watcher lifecycle and history-recording tests; appendSyncHistoryEntry spy correctly confirmed via waitForPendingHistoryWrites inside readSyncHistory

Sequence Diagram

sequenceDiagram
    participant Caller
    participant sync.ts
    participant sync-history.ts
    participant Disk

    Note over sync.ts: publishCodexCliSyncRun()
    sync.ts->>sync-history.ts: appendSyncHistoryEntry() [void, fire-and-forget]
    sync-history.ts->>sync-history.ts: withHistoryLock() — serialize writes
    sync-history.ts->>Disk: fs.appendFile(sync-history.ndjson) [retried on EBUSY/EPERM]
    sync-history.ts->>sync-history.ts: trimHistoryFileIfNeeded() [chunked tail-read, rewrite if >200]
    sync-history.ts->>Disk: fs.writeFile(sync-history-latest.json) [retried]

    Note over Caller: loadLastCodexCliSyncRun() — cold start
    Caller->>sync.ts: loadLastCodexCliSyncRun()
    alt lastCodexCliSyncRun already set
        sync.ts-->>Caller: clone of in-memory run
    else load promise in-flight
        sync.ts->>sync.ts: await lastCodexCliSyncRunLoadPromise (shared)
        sync.ts-->>Caller: clone of loaded run
    else first call
        sync.ts->>sync-history.ts: readLatestSyncHistory()
        sync-history.ts->>sync-history.ts: waitForPendingHistoryWrites()
        sync-history.ts->>Disk: fs.readFile(sync-history-latest.json) [no retry ⚠️]
        alt latest entry is codex-cli-sync
            sync-history.ts-->>sync.ts: entry
            sync.ts->>sync.ts: hydrateLastCodexCliSyncRunFromHistory() [guard: only sets if null]
            sync.ts-->>Caller: clone
        else fallback
            sync.ts->>sync-history.ts: readSyncHistory(kind=codex-cli-sync, limit=1)
            sync-history.ts->>Disk: readHistoryTail() [chunked, retried on EBUSY]
            sync-history.ts-->>sync.ts: entries
            sync.ts->>sync.ts: hydrateLastCodexCliSyncRunFromHistory()
            sync.ts-->>Caller: clone
        end
    end

    Note over LiveAccountSync: runReload() success/error
    LiveAccountSync->>sync-history.ts: appendSyncHistoryEntry() [void]
    sync-history.ts->>sync-history.ts: withHistoryLock()
    sync-history.ts->>Disk: append + trim + write latest
Loading

Comments Outside Diff (1)

  1. lib/codex-cli/sync.ts, line 322-330 (link)

    P1 in-flight load promise's .then() callback overwrites state after test reset

    __resetLastCodexCliSyncRunForTests sets lastCodexCliSyncRunLoadedFromHistory = null and lastCodexCliSyncRunLoadPromise = null synchronously, but if loadLastCodexCliSyncRun kicked off a history read that is still awaiting i/o at the moment of reset, the queued .then() callback (line 273-277 in the load chain) will fire after the reset and write the stale loaded run back into lastCodexCliSyncRunLoadedFromHistory. this leaves module-level state from the previous test in place, which can cause subsequent tests that check loadLastCodexCliSyncRun() to return a non-null value when they expect null — a classic flaky-test vector.

    the standard fix is a generation counter that the in-flight closure captures and checks before writing:

    let lastCodexCliSyncHistoryLoadGeneration = 0;
    
    // inside __resetLastCodexCliSyncRunForTests:
    lastCodexCliSyncHistoryLoadGeneration += 1;
    
    // inside loadLastCodexCliSyncRun, capture before starting:
    const thisGeneration = ++lastCodexCliSyncHistoryLoadGeneration;
    lastCodexCliSyncRunLoadPromise = (async () => { ... })()
        .catch(() => null)
        .then((loadedRun) => {
            if (thisGeneration !== lastCodexCliSyncHistoryLoadGeneration) {
                return null; // reset happened while we were in-flight
            }
            lastCodexCliSyncRunLoadedFromHistory = loadedRun ? cloneCodexCliSyncRun(loadedRun) : null;
            return loadedRun;
        })
        .finally(() => { lastCodexCliSyncRunLoadPromise = null; });
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-cli/sync.ts
    Line: 322-330
    
    Comment:
    **in-flight load promise's `.then()` callback overwrites state after test reset**
    
    `__resetLastCodexCliSyncRunForTests` sets `lastCodexCliSyncRunLoadedFromHistory = null` and `lastCodexCliSyncRunLoadPromise = null` synchronously, but if `loadLastCodexCliSyncRun` kicked off a history read that is still awaiting i/o at the moment of reset, the queued `.then()` callback (line 273-277 in the load chain) will fire after the reset and write the stale loaded run back into `lastCodexCliSyncRunLoadedFromHistory`. this leaves module-level state from the previous test in place, which can cause subsequent tests that check `loadLastCodexCliSyncRun()` to return a non-null value when they expect null — a classic flaky-test vector.
    
    the standard fix is a generation counter that the in-flight closure captures and checks before writing:
    
    ```typescript
    let lastCodexCliSyncHistoryLoadGeneration = 0;
    
    // inside __resetLastCodexCliSyncRunForTests:
    lastCodexCliSyncHistoryLoadGeneration += 1;
    
    // inside loadLastCodexCliSyncRun, capture before starting:
    const thisGeneration = ++lastCodexCliSyncHistoryLoadGeneration;
    lastCodexCliSyncRunLoadPromise = (async () => { ... })()
        .catch(() => null)
        .then((loadedRun) => {
            if (thisGeneration !== lastCodexCliSyncHistoryLoadGeneration) {
                return null; // reset happened while we were in-flight
            }
            lastCodexCliSyncRunLoadedFromHistory = loadedRun ? cloneCodexCliSyncRun(loadedRun) : null;
            return loadedRun;
        })
        .finally(() => { lastCodexCliSyncRunLoadPromise = null; });
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/sync-history.ts
Line: 253-268

Comment:
**`readLatestSyncHistory` silently swallows `EBUSY`/`EPERM` with no retry**

on windows, an antivirus scan or the os indexer briefly grabbing an exclusive lock on `sync-history-latest.json` right after a write will cause `fs.readFile` to throw `EBUSY` or `EPERM`. the catch here treats every non-`ENOENT` error identically — it logs at debug and returns `null`. the caller in `loadLastCodexCliSyncRun` then falls through to `readSyncHistory` (which does retry), so the data isn't permanently lost, but the silent null return triggers an unnecessary second full tail-read of the ndjson file on every transient lock.

`readSyncHistory` already wraps its reads with `retryHistoryWrite`; the same wrapper should be applied here:

```suggestion
export async function readLatestSyncHistory(): Promise<SyncHistoryEntry | null> {
	await waitForPendingHistoryWrites();
	try {
		const content = await retryHistoryWrite(() =>
			fs.readFile(getSyncHistoryPaths().latestPath, "utf8"),
		);
		const parsed = parseEntry(content);
		return parsed ? cloneEntry(parsed) : null;
	} catch (error) {
		const code = (error as NodeJS.ErrnoException).code;
		if (code !== "ENOENT") {
			log.debug("Failed to read latest sync history", {
				error: error instanceof Error ? error.message : String(error),
			});
		}
		return null;
	}
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/codex-cli/sync.ts
Line: 322-330

Comment:
**in-flight load promise's `.then()` callback overwrites state after test reset**

`__resetLastCodexCliSyncRunForTests` sets `lastCodexCliSyncRunLoadedFromHistory = null` and `lastCodexCliSyncRunLoadPromise = null` synchronously, but if `loadLastCodexCliSyncRun` kicked off a history read that is still awaiting i/o at the moment of reset, the queued `.then()` callback (line 273-277 in the load chain) will fire after the reset and write the stale loaded run back into `lastCodexCliSyncRunLoadedFromHistory`. this leaves module-level state from the previous test in place, which can cause subsequent tests that check `loadLastCodexCliSyncRun()` to return a non-null value when they expect null — a classic flaky-test vector.

the standard fix is a generation counter that the in-flight closure captures and checks before writing:

```typescript
let lastCodexCliSyncHistoryLoadGeneration = 0;

// inside __resetLastCodexCliSyncRunForTests:
lastCodexCliSyncHistoryLoadGeneration += 1;

// inside loadLastCodexCliSyncRun, capture before starting:
const thisGeneration = ++lastCodexCliSyncHistoryLoadGeneration;
lastCodexCliSyncRunLoadPromise = (async () => { ... })()
    .catch(() => null)
    .then((loadedRun) => {
        if (thisGeneration !== lastCodexCliSyncHistoryLoadGeneration) {
            return null; // reset happened while we were in-flight
        }
        lastCodexCliSyncRunLoadedFromHistory = loadedRun ? cloneCodexCliSyncRun(loadedRun) : null;
        return loadedRun;
    })
    .finally(() => { lastCodexCliSyncRunLoadPromise = null; });
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: test/sync-history.test.ts
Line: 258-292

Comment:
**missing vitest coverage for concurrent `loadLastCodexCliSyncRun` call deduplication**

the suite tests history recovery when the latest snapshot is missing, but there is no test verifying that two concurrent calls to `loadLastCodexCliSyncRun` (before any history load has been attempted) share a single i/o promise rather than both triggering separate file reads, and no test verifying that a real sync completing while the async load is in-flight does not get clobbered by the hydrate guard in `hydrateLastCodexCliSyncRunFromHistory`. these are the two riskiest concurrency paths introduced by this PR — they have explicit module-level state (`lastCodexCliSyncRunLoadPromise`, `lastCodexCliSyncRun`) protecting them, but zero test coverage to confirm the protection actually works.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix(sync): close his..."

Greptile also left 1 inline comment on this PR.

Copilot AI review requested due to automatic review settings March 16, 2026 08:51
@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

introduces a robust ndjson-based sync history system with concurrency control and retry logic. codex cli sync and live account sync now record operations to persistent history. new async loader reconstructs last sync run from history, enabling state recovery across process restarts.

Changes

Cohort / File(s) Summary
Sync history infrastructure
lib/sync-history.ts
New module implementing ndjson-backed history persistence with mutex-based write serialization, configurable retention (max 200 entries), retry logic for transient fs errors (ebusy, eperm, enotempty, eagain), and test hooks. exports types (CodexCliSyncHistoryEntry, LiveAccountSyncHistoryEntry, SyncHistoryEntry), core operations (append, readLatest, readTail), and test utilities (__reset, __getLastError).
Codex CLI sync integration
lib/codex-cli/sync.ts, test/codex-cli-sync.test.ts
Added async loadLastCodexCliSyncRun() exported function to hydrate last run from history with caching via lastCodexCliSyncHistoryLoadAttempted and lastCodexCliSyncRunLoadPromise. previewCodexCliSync now awaits loader instead of sync getter. publishCodexCliSyncRun appends history entry on completion. test setup uses configureSyncHistoryForTests and __resetSyncHistoryForTests, with assertions on persisted history entries.
Live account sync integration
lib/live-account-sync.ts, test/live-account-sync.test.ts
Added private recordHistory helper that constructs snapshot via toHistorySnapshot and calls appendSyncHistoryEntry with kind "live-account-sync". recordHistory invoked after reload success/error with reason and outcome. test setup mirrors codex-cli approach; tests spy on appendSyncHistoryEntry and validate persisted entries (kind, outcome, path).
Public API
lib/index.ts
Added export * from "./sync-history.js" to expose sync history module publicly.

Sequence Diagram(s)

sequenceDiagram
    participant CodexCli as Codex CLI Sync
    participant LiveSync as Live Account Sync
    participant History as Sync History
    participant FileSystem as Filesystem

    CodexCli->>History: publishCodexCliSyncRun()
    History->>History: withHistoryLock()
    History->>FileSystem: append entry (ndjson)
    History->>FileSystem: update latest snapshot
    History->>FileSystem: trimHistoryIfNeeded()
    FileSystem-->>History: done
    History-->>CodexCli: entry recorded

    LiveSync->>History: recordHistory(reason, outcome)
    History->>History: toHistorySnapshot()
    History->>History: withHistoryLock()
    History->>FileSystem: append entry (ndjson)
    History->>FileSystem: update latest snapshot
    FileSystem-->>History: done
    History-->>LiveSync: recorded

    CodexCli->>History: loadLastCodexCliSyncRun()
    History->>FileSystem: readLatestSyncHistory()
    FileSystem-->>History: latest entry
    History->>History: hydrateLastCodexCliSyncRunFromHistory()
    History-->>CodexCli: CodexCliSyncRun | null
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes


notes on review

concurrency risks flagged:

  • sync-history's in-memory mutex (historyMutex) provides single-process serialization only. cross-process writes are unprotected—if two node instances append simultaneously, file corruption is possible. consider documenting this assumption or adding file-locking.
  • retryHistoryWrite with exponential backoff handles transient fs errors but doesn't distinguish between ebusy (lock contention) and enotempty (unexpected state). windows ebusy behavior differs from posix; backoff timings may need tuning.
  • pendingHistoryWrites set isn't cleared if operations hang indefinitely; no timeout enforces cleanup.

missing regression coverage:

  • no test for concurrent writes from multiple processes or threads.
  • no test for windows-specific error codes (eacces on read-only shares, ebusy on file locks).
  • no test for what happens when latest snapshot file is corrupted while history is valid, or vice versa.
  • test/sync-history.test.ts doesn't exercise retry logic (ebusy simulation) or exponential backoff timing.

windows edge cases:

  • retryable errors list includes eagain, but windows doesn't emit eagain for files; verify ebusy and eperm cover intended cases.
  • file paths in history entries (snapshot.path) use forward slashes; confirm windows clients correctly handle "/" vs "\".
  • no explicit test for file-handle cleanup on windows after failed writes.

other observations:

  • loadLastCodexCliSyncRun caches via lastCodexCliSyncHistoryLoadAttempted and lastCodexCliSyncRunLoadPromise, but __resetLastCodexCliSyncRunForTests must be called to reset between tests. ensure all test suites invoke this—easy to miss in new tests.
  • recordHistory in live-account-sync catches and logs errors silently (debug level); confirm this is acceptable for auditing, or consider warn level.
  • readSyncHistory({ limit?, kind? }) uses tail-reading when limit provided but full scan when not; document performance implications for large history files.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed the title uses conventional commit format (feat scope: summary) and clearly describes the main change: adding result history and last-result logging to the sync system.
Description check ✅ Passed PR description covers summary, changes, validation checklist, docs governance, risk/rollback, and additional notes comprehensively.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fresh/11-sync-history-log
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fresh/11-sync-history-log
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds persisted sync result history (NDJSON + latest snapshot) and integrates history logging into Codex CLI sync and live account sync, with tests validating bounded tail reads and recovery behavior.

Changes:

  • Introduces lib/sync-history.ts for appending, trimming, and reading sync history (including a tail-reader for bounded reads).
  • Logs Codex CLI sync runs and live-account sync outcomes to history and hydrates “last run” from history when missing.
  • Extends existing Vitest suites and adds a new sync-history-focused test file.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/sync-history.test.ts New tests validating bounded tail reads and recovery of last Codex CLI sync run from history.
test/live-account-sync.test.ts Updates tests to configure/reset history and assert live sync history entries are recorded.
test/codex-cli-sync.test.ts Updates tests to configure/reset history and assert Codex CLI sync history persistence/hydration.
lib/sync-history.ts New sync history module (append/read/latest snapshot, tail read, trimming, test hooks).
lib/live-account-sync.ts Records live sync successes/errors into sync history with a snapshot tailored for history.
lib/codex-cli/sync.ts Appends Codex CLI sync runs to history and hydrates last run from latest/tail history.
lib/index.ts Re-exports sync-history from the library entrypoint.
Comments suppressed due to low confidence (1)

lib/live-account-sync.ts:1

  • basename does not accept null; if summarizeWatchPath is ever called with null this will throw at runtime (and should also fail typecheck unless the call sites guarantee non-null). Add a null guard (returning a placeholder like "<unknown>") or change the function signature to path: string and ensure callers never pass null.
import { type FSWatcher, promises as fs, watch as fsWatch } from "node:fs";

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

export function configureSyncHistoryForTests(directory: string | null): void {
historyDirOverride = directory ? directory.trim() : null;
export * from "./parallel-probe.js";
export * from "./session-affinity.js";
export * from "./live-account-sync.js";
export * from "./sync-history.js";
const reload = vi.fn(async () => {
throw new Error("reload failed");
});
const appendSpy = vi.spyOn(syncHistory, "appendSyncHistoryEntry");
Comment on lines 270 to +271
sync.stop();
appendSpy.mockRestore();
Comment on lines +170 to +190
async function trimHistoryFileIfNeeded(paths: SyncHistoryPaths): Promise<void> {
const content = await fs.readFile(paths.historyPath, "utf8").catch((error) => {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return "";
}
throw error;
});
if (!content) {
return;
}
const lines = content.split(/\r?\n/).filter(Boolean);
if (lines.length <= MAX_HISTORY_ENTRIES) {
return;
}
const trimmedContent = `${lines.slice(-MAX_HISTORY_ENTRIES).join("\n")}\n`;
await fs.writeFile(paths.historyPath, trimmedContent, {
encoding: "utf8",
mode: 0o600,
});
}
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-cli/sync.ts`:
- Around line 242-272: The race comes from checking
lastCodexCliSyncHistoryLoadAttempted then setting it after a small window; to
fix, make the check-and-set atomic inside loadLastCodexCliSyncRun by assigning
lastCodexCliSyncHistoryLoadAttempted = true immediately before (or as part of)
creating lastCodexCliSyncRunLoadPromise so no two callers can both pass the
check; update the function so the guard is: if
(!lastCodexCliSyncHistoryLoadAttempted) { lastCodexCliSyncHistoryLoadAttempted =
true; lastCodexCliSyncRunLoadPromise = (async () => { ... })().catch(() =>
null).finally(() => { lastCodexCliSyncRunLoadPromise = null; }); } and leave the
rest of the logic (awaiting lastCodexCliSyncRunLoadPromise and cloning via
cloneCodexCliSyncRun/hydrateLastCodexCliSyncRunFromHistory) unchanged.

In `@lib/sync-history.ts`:
- Around line 300-314: Remove the dead, blocking function
readLatestSyncHistorySync from lib/sync-history.ts: delete the entire function
definition and any export referencing readLatestSyncHistorySync, confirm there
are zero usages (search for readLatestSyncHistorySync) and run typechecks/tests
to ensure nothing else imports it; since it performs synchronous fs IO, do not
replace it—simply remove the symbol and related references to avoid blocking the
event loop.

In `@test/codex-cli-sync.test.ts`:
- Around line 1332-1342: Add a regression test that simulates transient
filesystem contention by mocking the appendFile call used by
appendSyncHistoryEntry (lib/sync-history.ts) to throw an EBUSY/EPERM/EAGAIN
error for the first N attempts and succeed thereafter, then invoke
commitPendingCodexCliSyncRun (lib/codex-cli/sync.ts) so the call path goes
through retryHistoryWrite (lib/sync-history.ts) and finally assert the history
was persisted/recoverable (e.g., use readSyncHistory/loadLastCodexCliSyncRun as
in the existing test); specifically, spy/mock fs.promises.appendFile (or the
wrapper used by appendSyncHistoryEntry) to fail twice with {code: 'EBUSY'} then
resolve, run the code that calls appendSyncHistoryEntry, and assert the retry
logic retried and the final persisted run outcome/summary matches expectations.

In `@test/sync-history.test.ts`:
- Around line 174-211: Add three regression tests: (1) for retry behavior, mock
node fs operations used by retryHistoryWrite to throw transient errors
EBUSY/EAGAIN/EPERM on the first N attempts and succeed thereafter, assert that
retryHistoryWrite was retried the expected number of times and that exponential
backoff delays (or at least increasing retry intervals) occurred; (2) for
concurrency serialization, fire multiple concurrent appendSyncHistoryEntry calls
and assert via withHistoryLock or by checking persisted history order/length
that appends were serialized (no lost/raced entries) and that withHistoryLock
was used to serialize; (3) for Windows filesystem edge cases, simulate
Windows-specific EBUSY/EAGAIN behavior during file removal in
__resetSyncHistoryForTests and during appends (mocking fs.unlink/fs.writeFile to
throw platform-specific errors) and assert cleanup and append logic handle
retries and do not leave corrupt state. Use spies/mocks for the specific
functions (retryHistoryWrite, withHistoryLock, appendSyncHistoryEntry,
__resetSyncHistoryForTests) to inject errors and verify expected
retries/serialization.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44978562-2ee0-49c2-a1bd-33d7a688275e

📥 Commits

Reviewing files that changed from the base of the PR and between 01ee35b and 13610d2.

📒 Files selected for processing (7)
  • lib/codex-cli/sync.ts
  • lib/index.ts
  • lib/live-account-sync.ts
  • lib/sync-history.ts
  • test/codex-cli-sync.test.ts
  • test/live-account-sync.test.ts
  • test/sync-history.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/index.ts
  • lib/live-account-sync.ts
  • lib/sync-history.ts
  • lib/codex-cli/sync.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-cli-sync.test.ts
  • test/sync-history.test.ts
  • test/live-account-sync.test.ts
🔇 Additional comments (23)
lib/index.ts (1)

20-20: lgtm, but note that test-only helpers are now public api.

the wildcard re-export exposes configureSyncHistoryForTests, __resetSyncHistoryForTests, __getLastSyncHistoryErrorForTests, and __getLastSyncHistoryPathsForTests from lib/sync-history.ts:322-361. the __ prefix convention signals internal/test use, so this is acceptable. tests in test/codex-cli-sync.test.ts, test/live-account-sync.test.ts, and test/sync-history.test.ts depend on these exports.

test/codex-cli-sync.test.ts (2)

77-78: proper test isolation for sync history.

configuring and resetting sync history in beforeEach ensures each test starts with a clean slate. good practice.


111-112: cleanup correctly resets sync history state.

teardown properly resets history and clears the directory override. this prevents test pollution.

lib/live-account-sync.ts (4)

115-122: good path redaction before persisting history.

toHistorySnapshot reduces the full path to basename, preventing leakage of sensitive directory structures in history entries. this aligns with the coding guidelines about not logging tokens or emails.


243-264: fire-and-forget history recording is intentional but worth noting.

recordHistory catches errors and logs at debug level, which is appropriate for non-critical telemetry. the fire-and-forget pattern ensures history failures don't break the main reload flow. concurrency is safe because appendSyncHistoryEntry uses withHistoryLock internally (lib/sync-history.ts:72-79).

test coverage exists at test/live-account-sync.test.ts:229-271.


348-348: history recorded on successful reload.

the void prefix makes this fire-and-forget, which is correct since history recording shouldn't block the reload flow.


358-362: history recorded on reload failure with error message.

captures the error message in the history entry for debugging purposes. the error message comes from the caught error, not from sensitive fields.

test/live-account-sync.test.ts (4)

67-68: proper test isolation for sync history in live-account-sync tests.

configuring and resetting sync history ensures each test starts clean.


81-82: cleanup correctly resets sync history state.

proper teardown prevents test pollution.


229-271: good coverage for history recording on reload failure.

the test spies on appendSyncHistoryEntry, triggers a reload failure, and verifies the history entry has the correct kind, outcome, reason, path, and snapshot.path fields. spy is properly restored at line 271.


388-395: verifies success outcome in history after multiple reloads.

checks that the latest history entry has outcome: "success" after the follow-up reload completes.

lib/codex-cli/sync.ts (3)

178-186: fire-and-forget history append with proper error handling.

the .catch() logs errors at debug level without breaking the main flow. no sensitive fields are logged. concurrency is safe because appendSyncHistoryEntry uses withHistoryLock internally.


315-316: test reset properly clears new state variables.

resetting lastCodexCliSyncHistoryLoadAttempted and lastCodexCliSyncRunLoadPromise ensures test isolation for the new loading logic.


691-691: preview now awaits async history load for cold-start recovery.

this adds I/O to the preview path but is necessary to populate lastSync from persisted history when the in-memory state is empty. acceptable tradeoff for the recovery feature.

test/sync-history.test.ts (3)

47-63: proper test setup and teardown with temp directory isolation.

creates a temp directory, configures sync history to use it, and cleans up after each test. good practice for deterministic tests.


65-136: good test for bounded tail-reading optimization.

verifies that readSyncHistory with limit: 1 uses readHistoryTail instead of loading the entire file via readFile. the spy assertion at line 135 confirms the optimization works.


138-172: good coverage for cold-start recovery path.

tests that loadLastCodexCliSyncRun can recover the last run from the ndjson history file when the latest snapshot file is deleted. this covers the fallback path in lib/codex-cli/sync.ts:259-263.

lib/sync-history.ts (6)

72-79: correct single-process mutex implementation.

withHistoryLock chains promises to serialize operations within a single node process. this doesn't protect against multi-process races, but that's acceptable for history logs where occasional duplicates are tolerable.


90-105: good retry logic for windows transient locks.

retryHistoryWrite handles EBUSY/EPERM/EAGAIN with exponential backoff (25ms → 50ms → 100ms → 200ms, 5 attempts). this aligns with the coding guidelines for windows filesystem IO.

per pr known issues, this path lacks explicit vitest coverage. see test/sync-history.test.ts review comment.


133-186: efficient bounded tail-reading for history.

readHistoryTail reads 8KB chunks from the end of the file, avoiding full-file reads for large histories. the implementation correctly handles:

  • empty files (line 141-143)
  • partial lines spanning chunks (lines 155-157)
  • kind filtering (line 164)
  • early exit when limit is reached (lines 166-168)

188-211: history trimming to bound file size.

trimHistoryFileIfNeeded rewrites the history file to the last 200 entries when it exceeds the cap. the trim logic reads 201 entries to detect overflow and only rewrites if necessary.

one note: if retryHistoryWrite fails on the rewrite (line 205-210), the history file could be left in an inconsistent state. consider catching and logging the error without throwing, since trim is best-effort.


213-247: correct append flow with lock, retry, trim, and latest update.

the sequence is:

  1. acquire lock
  2. ensure directory exists
  3. append entry with retry
  4. trim if oversized
  5. write latest snapshot with retry

pendingHistoryWrites tracking enables waitForPendingHistoryWrites to wait for in-flight writes before reads.


86-88: file permissions note for cross-platform.

mode: 0o700 for directories and mode: 0o600 for files are good security defaults on unix. on windows, these modes are largely ignored, but node doesn't throw. no issue here, just noting the cross-platform behavior.

Comment on lines +300 to +314
export function readLatestSyncHistorySync(): SyncHistoryEntry | null {
try {
const content = readFileSync(getSyncHistoryPaths().latestPath, "utf8");
const parsed = parseEntry(content);
return parsed ? cloneEntry(parsed) : null;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.debug("Failed to read latest sync history", {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# check if readLatestSyncHistorySync is used anywhere
rg -n "readLatestSyncHistorySync" --type=ts lib/ test/

Repository: ndycode/codex-multi-auth

Length of output: 162


🏁 Script executed:

# Additional check: verify no indirect calls via string lookups
rg -n "\"readLatestSyncHistorySync\"|'readLatestSyncHistorySync'" --type=ts lib/ test/

Repository: ndycode/codex-multi-auth

Length of output: 50


remove readLatestSyncHistorySync from lib/sync-history.ts:300-314—it's dead code with zero usages.

verified this function isn't called anywhere in lib/ or test/. it also blocks the event loop via readFileSync, which violates guidelines for lib/** around filesystem io patterns. no initialization logic depends on it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sync-history.ts` around lines 300 - 314, Remove the dead, blocking
function readLatestSyncHistorySync from lib/sync-history.ts: delete the entire
function definition and any export referencing readLatestSyncHistorySync,
confirm there are zero usages (search for readLatestSyncHistorySync) and run
typechecks/tests to ensure nothing else imports it; since it performs
synchronous fs IO, do not replace it—simply remove the symbol and related
references to avoid blocking the event loop.

Comment on lines +1332 to +1342
const history = await readSyncHistory({ kind: "codex-cli-sync" });
const lastHistory = history.at(-1);
expect(lastHistory?.kind).toBe("codex-cli-sync");
if (lastHistory?.kind === "codex-cli-sync") {
expect(lastHistory.run.outcome).toBe("changed");
expect(lastHistory.run.summary.addedAccountCount).toBe(1);
}
__resetLastCodexCliSyncRunForTests();
const persisted = await loadLastCodexCliSyncRun();
expect(persisted?.outcome).toBe("changed");
expect(persisted?.summary.addedAccountCount).toBe(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# check if any test explicitly covers retryHistoryWrite with EBUSY/EPERM injection
rg -n "retryHistoryWrite|EBUSY.*append|EPERM.*append" --type=ts test/

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# Find where retryHistoryWrite is defined
rg -n "retryHistoryWrite" --type=ts lib/

Repository: ndycode/codex-multi-auth

Length of output: 339


🏁 Script executed:

# Find appendSyncHistoryEntry and commitPendingCodexCliSyncRun functions
rg -n "appendSyncHistoryEntry|commitPendingCodexCliSyncRun" --type=ts lib/

Repository: ndycode/codex-multi-auth

Length of output: 750


🏁 Script executed:

# Check test file context around lines 1332-1342
sed -n '1320,1350p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1262


🏁 Script executed:

# Search for any EBUSY or EPERM error handling in lib
rg -n "EBUSY|EPERM" --type=ts lib/

Repository: ndycode/codex-multi-auth

Length of output: 1946


🏁 Script executed:

# Get the retryHistoryWrite function implementation
sed -n '90,120p' lib/sync-history.ts

Repository: ndycode/codex-multi-auth

Length of output: 858


🏁 Script executed:

# Search for any test error injection helpers or mocks for appendFile
rg -n "appendFile.*mock|mock.*appendFile|vi\.mock|afterEach.*jest" --type=ts test/ | head -20

Repository: ndycode/codex-multi-auth

Length of output: 1627


🏁 Script executed:

# Check if there are any tests that inject filesystem errors
rg -n "reject|throws|EBUSY|EPERM" --type=ts test/codex-cli-sync.test.ts | head -20

Repository: ndycode/codex-multi-auth

Length of output: 434


solid integration test for history persistence and recovery, but add regression test for retry logic.

verifies that appendSyncHistoryEntry (lib/sync-history.ts:213) is called during commitPendingCodexCliSyncRun (lib/codex-cli/sync.ts:274) and that loadLastCodexCliSyncRun can recover the run from persisted history after resetting in-memory state. good coverage for the cold-start recovery path.

however, retryHistoryWrite (lib/sync-history.ts:90) wraps all history writes with exponential backoff retry logic for EBUSY/EPERM/EAGAIN, but this test never exercises that retry path. add a regression test that injects transient EBUSY during the appendFile call inside appendSyncHistoryEntry to confirm the retry logic handles transient fs contention, especially on windows where EPERM can occur during concurrent file access.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-cli-sync.test.ts` around lines 1332 - 1342, Add a regression test
that simulates transient filesystem contention by mocking the appendFile call
used by appendSyncHistoryEntry (lib/sync-history.ts) to throw an
EBUSY/EPERM/EAGAIN error for the first N attempts and succeed thereafter, then
invoke commitPendingCodexCliSyncRun (lib/codex-cli/sync.ts) so the call path
goes through retryHistoryWrite (lib/sync-history.ts) and finally assert the
history was persisted/recoverable (e.g., use
readSyncHistory/loadLastCodexCliSyncRun as in the existing test); specifically,
spy/mock fs.promises.appendFile (or the wrapper used by appendSyncHistoryEntry)
to fail twice with {code: 'EBUSY'} then resolve, run the code that calls
appendSyncHistoryEntry, and assert the retry logic retried and the final
persisted run outcome/summary matches expectations.

Comment on lines +253 to +268
export async function readLatestSyncHistory(): Promise<SyncHistoryEntry | null> {
await waitForPendingHistoryWrites();
try {
const content = await fs.readFile(getSyncHistoryPaths().latestPath, "utf8");
const parsed = parseEntry(content);
return parsed ? cloneEntry(parsed) : null;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.debug("Failed to read latest sync history", {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 readLatestSyncHistory silently swallows EBUSY/EPERM with no retry

on windows, an antivirus scan or the os indexer briefly grabbing an exclusive lock on sync-history-latest.json right after a write will cause fs.readFile to throw EBUSY or EPERM. the catch here treats every non-ENOENT error identically — it logs at debug and returns null. the caller in loadLastCodexCliSyncRun then falls through to readSyncHistory (which does retry), so the data isn't permanently lost, but the silent null return triggers an unnecessary second full tail-read of the ndjson file on every transient lock.

readSyncHistory already wraps its reads with retryHistoryWrite; the same wrapper should be applied here:

Suggested change
export async function readLatestSyncHistory(): Promise<SyncHistoryEntry | null> {
await waitForPendingHistoryWrites();
try {
const content = await fs.readFile(getSyncHistoryPaths().latestPath, "utf8");
const parsed = parseEntry(content);
return parsed ? cloneEntry(parsed) : null;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.debug("Failed to read latest sync history", {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
}
export async function readLatestSyncHistory(): Promise<SyncHistoryEntry | null> {
await waitForPendingHistoryWrites();
try {
const content = await retryHistoryWrite(() =>
fs.readFile(getSyncHistoryPaths().latestPath, "utf8"),
);
const parsed = parseEntry(content);
return parsed ? cloneEntry(parsed) : null;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.debug("Failed to read latest sync history", {
error: error instanceof Error ? error.message : String(error),
});
}
return null;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/sync-history.ts
Line: 253-268

Comment:
**`readLatestSyncHistory` silently swallows `EBUSY`/`EPERM` with no retry**

on windows, an antivirus scan or the os indexer briefly grabbing an exclusive lock on `sync-history-latest.json` right after a write will cause `fs.readFile` to throw `EBUSY` or `EPERM`. the catch here treats every non-`ENOENT` error identically — it logs at debug and returns `null`. the caller in `loadLastCodexCliSyncRun` then falls through to `readSyncHistory` (which does retry), so the data isn't permanently lost, but the silent null return triggers an unnecessary second full tail-read of the ndjson file on every transient lock.

`readSyncHistory` already wraps its reads with `retryHistoryWrite`; the same wrapper should be applied here:

```suggestion
export async function readLatestSyncHistory(): Promise<SyncHistoryEntry | null> {
	await waitForPendingHistoryWrites();
	try {
		const content = await retryHistoryWrite(() =>
			fs.readFile(getSyncHistoryPaths().latestPath, "utf8"),
		);
		const parsed = parseEntry(content);
		return parsed ? cloneEntry(parsed) : null;
	} catch (error) {
		const code = (error as NodeJS.ErrnoException).code;
		if (code !== "ENOENT") {
			log.debug("Failed to read latest sync history", {
				error: error instanceof Error ? error.message : String(error),
			});
		}
		return null;
	}
}
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

@ndycode ndycode changed the base branch from fresh/06-docs-frontdoor-cleanup to dev March 18, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants