Skip to content

perf(asv3): SessionLogWriter O(turn) append + Mirror facade (WP-5)#406

Open
Luis85 wants to merge 8 commits into
developfrom
claude/asv3-wp05-session-log-append
Open

perf(asv3): SessionLogWriter O(turn) append + Mirror facade (WP-5)#406
Luis85 wants to merge 8 commits into
developfrom
claude/asv3-wp05-session-log-append

Conversation

@Luis85
Copy link
Copy Markdown
Owner

@Luis85 Luis85 commented May 17, 2026

Summary

  • Port extension (ADR-008). Add VaultPort.appendFile(path, content) and implement across all three runtime adapters (ObsidianBridge via vault.adapter.append, MockBridge with in-memory concat + a narrow calls recorder, LocalStorageBridge via read+concat+write).
  • Writer hot path (closes the 2026-05-17 audit's O(N²) trigger). SessionLogWriter now keeps a per-path LogPathCache of {frontmatter, body} seeded once on first append, calls appendFile for each new turn block, and writeFile only to rewrite the frontmatter window with the bumped updated: timestamp. The DoD's 100-turn probe drops from 100 readFile + 100 writeFile down to 0 readFile + 100 appendFile + 101 writeFile on a fresh thread (100 turns of body re-reads eliminated).
  • SessionLogMirror facade. New thin wrapper with mirrorTurn (fire-and-forget) + mirrorProposalDecision (await-required) + a createSessionLogMirror factory. Replaces every direct SessionLogWriter import outside src/application/chat/ChatTurnOrchestrator, commitFileWriteProposal, useSessionLogMirror (renamed from useSessionLogWriter), useProposalDecisions, and ChatSidebar.vue all consume the mirror. Lint scope rule added so the writer cannot be re-imported from UI by accident.

DoD checklist

  • npm audit --audit-level=high --omit=dev clean (0 vulnerabilities)
  • npm run typecheck clean
  • npm run lint clean (0 errors; 24 pre-existing warnings)
  • npm run test — 1878 / 1878 passing, including 2 new DoD tests in SessionLogWriter.test.ts, a delegation suite in SessionLogMirror.test.ts, and a cross-adapter VaultPort.appendFile contract test
  • npm run build + npm run build:web succeed
  • npm run docs:api succeeds
  • O(turn) closed: 100-turn test asserts mockBridge.calls.appendFile.length === 100 and readFile.length === 0 on a fresh thread (≤ 1 for the resumed-session path)
  • Mirror facade is the single caller of SessionLogWriter outside application/chat/, enforced by no-restricted-imports

Test plan

  • Manual sanity in the standalone web build: send several chat turns on a single thread, confirm the session log under specs/<feature>/sessions/<sid>.md accumulates ## user / ## assistant blocks and that updated: advances with each turn while created: stays pinned.
  • Confirm the proposal-commit pipeline still surfaces SESSION_LOG_FAILED when the audit-row append rejects (covered by commitFileWriteProposal.test.ts).
  • Verify lint blocks a hypothetical import { SessionLogWriter } from '@/application/chat/SessionLogWriter' inside src/ui/**.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj


Generated by Claude Code

claude added 2 commits May 17, 2026 13:34
Replaces SessionLogWriter's O(turns²) rewrite-per-append pattern with an
O(turn) append gated behind a new VaultPort.appendFile port method, and
introduces a SessionLogMirror facade that becomes the public surface for
session-log writes outside src/application/chat/.

Port extension (ADR-008):
- VaultPort: add appendFile(path, content).
- ObsidianBridge: delegate to vault.adapter.append (creates the file on
  first call when absent).
- MockBridge: in-memory concat append + narrow calls recorder
  ({writeFile, appendFile, readFile}) so I/O accounting can be asserted.
- LocalStorageBridge: read+concat+write (GH Pages demo only).

Writer hot path:
- Per-path LogPathCache holds the frontmatter + body in memory after
  the first seeding read. Subsequent turns call appendFile for the new
  block and writeFile only to rewrite the frontmatter window with the
  bumped updated: timestamp.
- Eliminates the per-turn body re-read that drove the audit's 83.21%
  statement-coverage gap and the O(N²) trigger.

Facade:
- SessionLogMirror exposes mirrorTurn (fire-and-forget) +
  mirrorProposalDecision (await-required) + a createSessionLogMirror
  factory. UI never imports SessionLogWriter directly; a new
  no-restricted-imports rule enforces this outside src/application/chat/.
- ChatTurnOrchestrator, commitFileWriteProposal, useSessionLogMirror
  composable (renamed from useSessionLogWriter), useProposalDecisions,
  and ChatSidebar.vue migrate to the facade.

Tests:
- New: SessionLogMirror.test.ts (delegation).
- New: VaultPort.appendFile.test.ts (cross-adapter contract).
- New: SessionLogWriter "100 turns" DoD test asserting
  appendFile.length === 100 and readFile.length === 0 on a fresh path,
  plus a resumed-session test bounding reads to the seed.
- Latency and mutex tests in SessionLogWriter.test.ts updated to track
  the appendFile-shaped hot path.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f0d2453b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const nextFrontmatter = buildFrontmatter(nextFields)
cache.fields = nextFields
cache.frontmatter = nextFrontmatter
await this.vault.writeFile(resolvedPath, `${nextFrontmatter}${cache.body}`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop rewriting full session body after each append

appendBlock still issues writeFile(resolvedPath, ${nextFrontmatter}${cache.body}) on every turn, and cache.body contains the entire accumulated log. That means each append rewrites all prior turns, so cumulative write volume remains O(N²) for N turns (with an extra appendFile call on top), which defeats the intended hot-path performance fix for long sessions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in bd2b49d. You're right — the surface DoD passed (appendFile fired once per turn) but appendBlock was also calling writeFile(${frontmatter}${cache.body}) on every turn, with cache.body carrying every accumulated turn. Cumulative write volume stayed O(N²) and the "O(turn) closed" claim was misleading.

Architectural fix: body is append-only on disk; frontmatter updated: is debounced. appendBlock no longer calls writeFile after the seed — it does exactly one appendFile(blockOnWire) per turn and schedules a debounced frontmatter flush via scheduleFrontmatterFlush (default 30 s, configurable via flushDebounceMs). When the timer fires, flushFrontmatter reads the live on-disk body once, splices the new frontmatter, and writes the result. A new flushAll() cancels pending debounces and drains single-flight per path — production callers invoke it on plugin teardown; tests use it for deterministic assertions. Exposed on SessionLogMirror so callers outside src/application/chat/ don't need to import the writer directly.

LogPathCache.body is gone; the cache only tracks the latest FrontmatterFields and a bodyEndsWithNewline boolean. The DoD test now asserts the new contract on a fresh 100-turn probe: appendFile.length === 100, writeFile.length === 1 (seed only — no per-turn rewrite), readFile.length === 0 (no flush yet). After flushAll(): +1 readFile and +1 writeFile (the frontmatter rewrite splicing over the live body). Brief DoD and loop-state.md updated to reflect the corrected semantics rather than silently rewriting history.


Generated by Claude Code

Comment on lines +547 to +550
let cache = this.cacheByPath.get(resolvedPath)
if (cache === undefined) {
cache = await this.seedCache(resolvedPath, thread, at)
this.cacheByPath.set(resolvedPath, cache)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve out-of-band edits before flushing cached body

Once a path is cached, subsequent writes are generated from in-memory cache.body and flushed back wholesale. If the session log is modified on disk between turns (for example, manual edits in the vault), those changes are overwritten by stale cache content on the next append because this path does not re-merge current file contents before writeFile.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Resolved automatically by the P1 fix in bd2b49d. The root cause you flagged is the same as P1: the cached body was the source of truth on every rewrite, so out-of-band edits made on disk between turns were silently overwritten by the next appendBlock.

After the architectural fix, the body is never rewritten from in-memory state. appendBlock only calls appendFile(blockOnWire) per turn — that's a pure tail-append, and any prior on-disk content (including manual edits) survives untouched. The debounced frontmatter flush in flushFrontmatter reads the live on-disk body fresh, splices the new FrontmatterFields, and writes the result — so even the once-per-debounce-window writeFile preserves whatever the user did between turns.

Added a regression test for this: out-of-band body edits between turns survive the debounced flush (Codex P2) in tests/application/chat/SessionLogWriter.test.ts. It seeds turn 1, edits the on-disk body to insert a ## annotation block, runs turn 2 + flushAll(), and asserts the annotation is still present alongside the new turn-2 content and the advanced updated: timestamp. The LogPathCache.body field is gone entirely so there's no path left for stale cache state to clobber the body.


Generated by Claude Code

…ex P1+P2)

Codex review on PR #406 flagged two findings that share a root cause:
the WP-5 `appendBlock` issued `writeFile(${frontmatter}${cache.body})`
on every turn so cumulative write volume stayed O(N²) (P1), and the
in-memory body cache was rewritten back to disk wholesale on every
flush so out-of-band edits between turns were silently overwritten
(P2). The surface DoD passed ("appendFile is called per turn"); the
intended perf win didn't land.

Architectural fix: body is append-only on disk; frontmatter `updated:`
is debounced.

- Drop the body string from `LogPathCache` (replace with a
  `bodyEndsWithNewline` boolean used to compose the leading separator).
- `appendBlock` no longer calls `writeFile` after the seed — it writes
  exactly one `appendFile` per turn.
- New `scheduleFrontmatterFlush` arms a `setTimeout`-driven debounce
  (default 30 s, configurable via `flushDebounceMs`). When the timer
  fires, `flushFrontmatter` reads the *live* on-disk body, splices the
  latest `FrontmatterFields`, and writes the result. P2 is resolved
  automatically — the read picks up out-of-band edits.
- New `flushAll()` cancels pending debounces and drains the flush
  single-flight per path. Production callers invoke it on plugin
  teardown; tests use it to assert post-flush state deterministically.
  Exposed on `SessionLogMirror` so UI/orchestrator callers don't need
  to import the writer directly.
- Inject `setTimeout` / `clearTimeout` via constructor options so the
  application layer stays free of Obsidian runtime globals; the plugin
  layer can pass `activeWindow.*` for popout-window compatibility.

DoD on a fresh 100-turn probe (Codex P1+P2 round-1):
- `appendFile.length === 100` (one body delta per turn)
- `writeFile.length === 1` (seed frontmatter only — no per-turn rewrite)
- `readFile.length === 0` (no flush yet)
- After `flushAll()`: `+1 readFile` + `+1 writeFile` (frontmatter
  rewrite spliced over the live body — P2 fix).

Test updates: TEST-ASM-033 rewritten to call `flushAll()` before
asserting on `updated:`; the O(turn) DoD test asserts the new counts;
a P2-resolution test inserts an out-of-band annotation between turns
and confirms it survives the next flush; two `ChatSidebar.proposalFlow`
audit-row tests switch from `writeFile`-spy to `appendFile`-spy (the
audit row now lands via `appendFile`).

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd2b49d420

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +795 to +799
const existing = await this.vault.readFile(resolvedPath)
const split = splitFrontmatterAndBody(existing)
const body = split?.body ?? existing
const nextFrontmatter = buildFrontmatter(fields)
await this.vault.writeFile(resolvedPath, `${nextFrontmatter}${body}`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize frontmatter flush with append queue

Running doFlush from the debounced timer allows a readFilewriteFile cycle to race with appendBlock because this path does not use _runQueued's per-file mutex. If a new turn appends after readFile but before this writeFile, the stale body snapshot is written back and the just-appended block is lost. This can happen in normal usage when a message arrives around the debounce boundary, so the flush should be serialized through the same queue (or otherwise made race-safe).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed — the round-1 debounced flush opened a readFile → writeFile race against appendBlock. If an appendFile for turn N+1 landed between the flush's body read and its frontmatter write, the stale snapshot was written back and the freshly-appended turn was lost. The trigger window is small but reachable in normal use (a message arriving on the debounce boundary), so this is a correctness bug, not a theoretical one.

Fix in 570b1c0: I extracted _enqueue(queueKey, op) from _runQueued and routed doFlush through the same primitive. PendingFlush now tracks the pre-suffix basePath as its queueKey, set on first schedule, so the flush enqueues onto the same per-file mutex chain appendBlock uses. Either order is now safe: the flush either reads after every queued append has landed and writes them all back, or yields the mutex so the pending append lands on top of the freshly-written frontmatter. Per-path only — different session paths still run in parallel; no global mutex. The single-flight inFlight coalescing stays on top so flushAll() remains idempotent.

Regression test added in tests/application/chat/SessionLogWriter.test.ts ("debounced frontmatter flush does not race a concurrent appendBlock"): it parks the flush on a VaultPort.readFile barrier mid-cycle, fires a concurrent appendUserAssistant, releases the barrier, drains, and asserts every turn survives on disk. Verified it FAILS on the round-1 head (bd2b49d loses u2/a2) and PASSES on the round-2 commit. Pre-PR gate green: npm audit 0 vulns, typecheck, lint (0 errors), npm test 1880/1880 (+1), build, build:web, docs:api.


Generated by Claude Code

…odex P1 round-2)

The round-1 debounced `flushFrontmatter` bypassed the per-file mutex
`appendBlock` uses, opening a `readFile → writeFile` race that could
silently drop a turn block. If a new `appendFile` for turn N+1 landed
between the flush's body read and its frontmatter write, the stale body
snapshot was written back and the freshly-appended block was lost.

Fix: extract `_enqueue(queueKey, op)` from `_runQueued` and route
`doFlush` through it. Track the pre-suffix `basePath` on `PendingFlush`
so the flush registers under the same queue key as the appends, sharing
the same mutex chain. Per-path serialisation only — different session
paths still flush in parallel; no global mutex.

Regression test in `SessionLogWriter.test.ts` parks the flush on a
`readFile` barrier mid-cycle, fires a concurrent `appendUserAssistant`,
and asserts every turn survives on disk. Verified the test FAILS on
`bd2b49d` (round-1 code) and PASSES on this commit.

Refs: Codex thread 3254810244, line 799.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 570b1c0335

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +826 to +830
// Coalesce a concurrent flush request into the in-flight one. The
// in-flight flush will pick up the latest `pendingFields` before its
// own write, so we don't need a second pass.
await pending.inFlight
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-run flush after awaiting in-flight write

When flushFrontmatter is called while another flush is already inFlight, this branch just awaits the existing promise and returns. If a new append arrives during that in-flight flush, pending.pendingFields is updated and flushAll() may clear its timer, then hit this early return; the new frontmatter snapshot is left pending with no timer and never written. In plugin teardown (the documented flushAll() use case), this can silently drop the latest updated: timestamp despite the turn body being appended, so flushFrontmatter should re-check pendingFields after awaiting and perform another flush pass if needed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid P1 — fixed in c272857.

flushFrontmatter now re-checks pending.pendingFields after await pending.inFlight. When a snapshot landed during the wait (because an appendBlock was queued ahead of the flush's doFlush in the per-path mutex and its scheduleFrontmatterFlush ran during the in-flight window), the method recurses on the fresh path so the queued updated: snapshot lands on disk instead of being silently abandoned. As a complementary defence, flushAll() now drains in a bounded loop (cap 4 passes) so any snapshot armed during the doFlush's own readFile/writeFile cycle is also picked up before teardown returns. Each pendingFields update is a monotonic forward replacement, so the recursion + loop converge in ≤ 2 passes worst-case.

Regression test "flushAll() drains pendingFields armed during an in-flight flush (Codex P1 round-3)" in tests/application/chat/SessionLogWriter.test.ts parks appendFile so turn 2 sits in the mutex with its body not yet on disk, fires turn 1's debounce timer (queueing doFlush behind turn 2), releases appendFile so turn 2's scheduleFrontmatterFlush runs during the in-flight flush, parks readFile to keep the flush in-flight, then calls flushAll(). Verified the test FAILS on 570b1c0 (frontmatter updated: reads turn 1's timestamp) and PASSES on c272857 (updated: reflects turn 2's timestamp). Body content for turn 2 was always on disk via appendFile; the regression specifically asserts the frontmatter advance the bug used to drop.

Pre-PR gate all green on c272857: npm audit 0 vulns, typecheck, lint (0 errors, 25 pre-existing warnings), tests 1881/1881 (+1 vs round-2), build, build:web, docs:api.


Generated by Claude Code

… Codex P1 round-3)

`SessionLogWriter.flushFrontmatter` used to return unconditionally
after awaiting an in-flight flush, so any `pendingFields` snapshot
queued during the wait was silently dropped. The scenario that
triggers it: an `appendBlock` queued ahead of the flush's `doFlush`
in the per-path mutex runs its `scheduleFrontmatterFlush` AFTER the
flush has captured + nulled `pendingFields` but BEFORE `doFlush`
actually runs, repopulating `pendingFields` with a strictly newer
snapshot. On plugin teardown (the documented `flushAll()` path),
`flushAll` clears the re-armed timer and calls `flushFrontmatter`,
which sees `inFlight !== null` and returns early — the new
`updated:` timestamp is left armed with no timer and never written
to disk, even though the turn body itself was already on disk via
`appendFile`.

This patch adds two complementary defences: `flushFrontmatter`
re-checks `pendingFields` after `await pending.inFlight` and
recurses on the fresh path if a snapshot is still queued, and
`flushAll` drains in a bounded loop (cap 4 passes) so a snapshot
armed during the doFlush's own readFile/writeFile cycle also lands
before teardown returns. Each `pendingFields` update is a monotonic
forward replacement, so the recursion + loop converge in ≤ 2 passes
worst-case.

Regression test in `tests/application/chat/SessionLogWriter.test.ts`
parks `appendFile` so turn 2 sits in the mutex with its body not
yet on disk, fires turn 1's debounce timer (queueing `doFlush`
behind turn 2), releases `appendFile` so turn 2's
`scheduleFrontmatterFlush` runs during the in-flight flush, parks
`readFile` to keep the flush in-flight, then calls `flushAll()`.
Pre-fix the on-disk `updated:` reads turn 1's timestamp; post-fix
it reads turn 2's. Verified the test FAILS on 570b1c0 and PASSES
on this commit.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c272857905

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1007 to +1008
const frontmatter = buildFrontmatter(fields)
await this.vault.writeFile(resolvedPath, frontmatter)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve existing log body on malformed frontmatter

When seedCache sees an existing session log whose frontmatter cannot be parsed, this branch rewrites the file with frontmatter only before appending the new turn. In that scenario (for example, a manually edited or partially corrupted log), all pre-existing body content is deleted, so historical turns are lost on the next write. The fallback should keep the existing file content as body data (or move aside/conflict) instead of truncating it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Thanks — valid P1 confirmed and fixed in 952ae30. You're right that the previous fallback wrote ${frontmatter} only and silently truncated every prior turn on disk. That's the worst kind of data loss: silent, triggered by a user trying to be helpful (manually editing the log in Obsidian and accidentally breaking a delimiter, or saving the file as raw text).

The fix preserves the existing blob verbatim. When splitFrontmatterAndBody(existing) === null, seedCache now writes ${frontmatter}${existing} — the user's data survives byte-for-byte under a fresh frontmatter window. The file structure is unusual (new frontmatter, then the old malformed bytes inside the body) but every byte is recoverable by hand. bodyEndsWithNewline is derived from the preserved blob's tail so the next appendBlock composes a correct separator. A warn log with the resolved path + redacted sessionId fires on entry to the malformed branch so a maintainer can spot the unusual event.

Three regression tests under SessionLogWriter malformed-frontmatter seed (Codex P1 round-4) cover the branch directly (malformed frontmatter with a multi-turn body, raw-text file with no frontmatter at all, and a two-turn-plus-flush sequence). Each asserts every byte of the original on-disk content survives the next write. Test count: 1884/1884 passing (+3 vs round-3). All four prior fixes (P1 rounds 1–3, P2) keep working. Full pre-PR gate green locally.


Generated by Claude Code

…Codex P1 round-4)

Codex P1 round-4 (thread 3254865902): the WP-5 cache rework introduced
a silent data-destruction path in `seedCache`. When the on-disk
session log had malformed frontmatter (e.g. the user manually edited
the file in Obsidian and accidentally broke a YAML delimiter, or
saved the file as raw text without a frontmatter block), the
fallback wrote `${frontmatter}` only before appending the new turn
— silently truncating every prior turn the user had on disk. The
worst kind of data loss, because it's triggered by a user trying to
be helpful.

The fix preserves the existing blob verbatim. When
`splitFrontmatterAndBody` returns null, `seedCache` now writes
`${frontmatter}${existing}` and derives `bodyEndsWithNewline` from
the preserved blob's tail. The user's data survives byte-for-byte;
the file structure is unusual (new frontmatter window + old
malformed bytes inside the body) but every byte is recoverable.
Subsequent reads parse the new frontmatter cleanly. A `warn` log
fires on the malformed-frontmatter event so the next maintainer
sees it in the console.

Three regression tests in `tests/application/chat/SessionLogWriter.test.ts`
cover the malformed-frontmatter branch directly: malformed
frontmatter with a multi-turn body, file with no frontmatter at
all (raw text), and a two-turn-plus-flush sequence. Each asserts
that every byte of the original on-disk content survives the next
write. Verified the tests FAIL on the round-3 head (c272857) and
PASS on the round-4 fix. Test count: 1884/1884 passing (+3 vs
round-3). The four prior fixes (P1 rounds 1–3, P2) keep working.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 952ae30c4e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +44 to +49
export function useSessionLogMirror(): UseSessionLogMirror {
const vault = useVaultPort()
const logger = useLoggerPort()
const settings = useSettingsPort()
let cached: SessionLogMirror | null = null
let cachedSpecsFolder: string | null = null
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Flush pending session-log writes during teardown

useSessionLogMirror now builds a writer that debounces frontmatter updates, but this composable never registers a teardown path (flushAll) for the cached mirror. If the sidebar/plugin is closed (or the app exits) within the debounce window after a turn append, the turn body is already on disk but the latest updated: frontmatter value can be lost because it was only staged in memory. Please ensure teardown drains pending flushes before unmount/unload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 90882f2. Both teardown surfaces now drain the debounced frontmatter flush.

Composable teardownuseSessionLogMirror registers an onBeforeUnmount hook (guarded by getCurrentInstance() !== null so non-component callers still work) that calls void cached.flushAll(). Vue's teardown runs synchronously and cannot await, but the writer's per-path mutex (round-2) plus the bounded re-drain loop in flushAll (round-3) complete the work on microtasks regardless. The hook deregisters the mirror from the module-level activeMirrors set before firing the drain so the plugin-level path cannot double-flush a retiring writer. The specsFolder-invalidation branch in getMirror() retires the replaced mirror's entry for the same reason.

Plugin teardownSpecoratorPlugin.onunload() now invokes void flushAllActiveSessionLogMirrors() immediately after detachLeavesOfType(...) and before bridge.hideAllNotices(). The exported drain wraps each live mirror's flushAll() in Promise.allSettled so an independent failure on one path cannot strand the others. Fire-and-forget shape matches the existing chatThreads flush (Codex P1, PR #346) — Obsidian's onunload contract is synchronous so we cannot await.

Architectural note — the mirror is per-ChatSidebar-mount, not a global singleton (useSessionLogMirror() is invoked exactly once in ChatSidebar.vue's <script setup>). The plugin-level registry exists not for fan-out but because Vue's onBeforeUnmount cannot reach the app-exit / plugin-disable code path that runs through onunload. Tests: +5 in useSessionLogMirror.test.ts (unmount drain, lazy no-op, multi-mount drain, double-flush prevention, end-to-end on-disk frontmatter check) plus a new main.session-log-flush.test.ts (+3 covering the onunload drain contract). Total: 1884 → 1892. Pre-PR gate green across audit / typecheck / lint / test / build / build:web / docs:api.


Generated by Claude Code

…WP-5 Codex P2 round-2)

The `useSessionLogMirror` composable now registers an `onBeforeUnmount`
hook that calls `void cached.flushAll()` when its host component unmounts.
The underlying `SessionLogWriter` debounces the `updated:` frontmatter
rewrite for up to 30 s after each turn append (the body itself lands
synchronously via `appendFile`). Without a teardown hook, closing the
sidebar inside that window dropped the pending frontmatter snapshot, and
the next session load showed the new turn body with a stale `updated:`
timestamp.

A module-level `activeMirrors` registry plus a new exported
`flushAllActiveSessionLogMirrors()` give the plugin layer a single
handle on every live mirror. `SpecoratorPlugin.onunload()` now invokes
the drain immediately after `detachLeavesOfType(...)`, matching the
existing fire-and-forget pattern used for `chatThreads` (Codex P1,
PR #346). The composable's unmount hook deregisters the mirror BEFORE
firing `flushAll()` so the plugin-level drain cannot double-flush a
retiring writer, and the `specsFolder`-invalidation branch in
`getMirror()` retires the replaced mirror's registry entry for the
same reason. The fix composes cleanly with WP-5 rounds 1-4: the
per-path mutex (round-2), bounded re-drain loop (round-3), and
malformed-frontmatter preservation (round-4) all continue to apply.

Adds 5 new regression tests to `useSessionLogMirror.test.ts` (unmount
drains the cached mirror, drain registry covers multi-mount, deregister
order prevents double-flush, end-to-end on-disk frontmatter check) and
a new `tests/plugin/main.session-log-flush.test.ts` (3 tests covering
the `onunload()` drain contract). Test count: 1884 → 1892.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90882f2cdd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +159 to +161
if (cached !== null) {
activeMirrors.delete(cached)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Flush replaced mirror before deregistering it

When specsFolder changes, this branch removes the previous cached mirror from activeMirrors and replaces it without draining the old writer first. Because SessionLogWriter defers frontmatter writes with a debounce, the old mirror can still hold a pending updated: flush; after it is deregistered, flushAllActiveSessionLogMirrors() during plugin teardown will skip it, so closing/disabling the plugin within that debounce window can drop the latest frontmatter update for the old path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in f64a6ec (P2 round-3).

The specsFolder-invalidation branch in useSessionLogMirror.ts now calls void cached.flushAll() before activeMirrors.delete(cached). Drain then deregister: the drain is fire-and-forget (matching the existing onBeforeUnmount hook in the same file — the call site is inside the synchronous inFlight Promise constructor and cannot await without serialising every subsequent getMirror() caller behind the old debounce window). The writer's per-path mutex + bounded re-drain loop (round-3 fix) completes the drain on microtasks. The microtask overlap with flushAllActiveSessionLogMirrors() is safe because flushAll() is idempotent — the bounded re-drain loop folds concurrent invocations into a single per-path flush.

Regression test: tests/ui/composables/useSessionLogMirror.test.ts — "drains the retired mirror when specsFolder changes mid-session (Codex P2 round-3, PR #406)". Mounts the host with specsFolder="specs", awaits a mirror, spies flushAll, flips the setting to "docs/specs", awaits a second mirror, asserts the new mirror is a fresh instance and that the old mirror's flushAll was invoked exactly once. Verified FAIL on 90882f2, PASS on f64a6ec. Full pre-PR gate (audit / typecheck / lint / 1893 tests / build / build:web / docs:api) is green.


Generated by Claude Code

…P2 round-3)

When the user changed `specsFolder` mid-session, the composable
deregistered the old mirror from `activeMirrors` without draining its
pending debounced frontmatter flush. A plugin teardown inside the
30 s debounce window then dropped the latest `updated:` snapshot for
the old path because `flushAllActiveSessionLogMirrors()` could no
longer see it.

Drain THEN deregister. The fire-and-forget shape matches the existing
`onBeforeUnmount` hook in the same file; the writer's per-path mutex
+ bounded re-drain loop completes the work on microtasks.

Regression test fails on 90882f2 and passes after the fix (Codex
thread 3254908179).

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
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