Skip to content

fix(daemon): persist the pending-output counter across empty incremental takes (STA-4297) - #14496

Merged
brennanb2025 merged 2 commits into
mainfrom
brennanb2025/sta-4297-empty-take-continuity
Aug 14, 2026
Merged

brennanb2025 merged 2 commits into
mainfrom
brennanb2025/sta-4297-empty-take-continuity

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

The daemon keeps a counter that says "this is batch number N of your terminal's history." Every time it drains pending output it bumped that counter — even when there was nothing to drain, in which case nothing gets written to disk. So the counter said "batch 5" while the disk only ever got up to batch 4.

Later, when the app reattaches to that terminal, it checks "does the disk agree with the counter?" before trusting the deep saved history. It didn't agree, so it threw away the deep history and kept only the last ~1000 visible rows. Now only an empty drain leaves the counter unchanged; snapshots, records, and overflow still advance it, so genuine lost writes remain detectable while empty ticks no longer manufacture a false gap.

What Changed

Session.takePendingOutput now advances pendingOutputSeq only for takes that carry persistence state:

  • a snapshot take (the seq is stamped into the checkpoint file), or
  • a take carrying records, or one flagged overflowed (a real log hole worth recording).

An empty incremental take repeats the previous seq instead of advancing it. TakePendingOutputResult.seq's doc comment now states the exact contract: snapshot, record, and overflow takes advance; empty incremental takes repeat the prior value.

Scope is deliberately one condition in session.ts. Nothing in checkpoint admission, the per-session queue, runWithDeadline, or final-checkpoint deadline behavior is touched (STA-4228 / STA-4229 territory, #14346 / #14385). #14193 and #13061 are unmodified.

Why

An empty incremental take advanced the counter without ever writing a log batch, so the in-memory seq ran permanently ahead of the log:

  • session.ts takePendingOutput bumped pendingOutputSeq unconditionally, including on zero records.
  • daemon-pty-adapter.ts returns early on take.records.length === 0, before historyManager.appendIncrements — so that advanced seq is never persisted.
  • After a warm reattach the adapter requires restoreInfo.pendingOutputSeq === take.seq - 1.
  • history-reader.ts stamps pendingOutputSeq from the last log batch (or, when the log is header-only, from the checkpoint).

Net: the first post-reattach compact fails the continuity proof and commits the live snapshot over the deep checkpoint. No corruption, but it destroys exactly the restore depth #14193 exists to provide.

Why this shape. The release owner named two options: (a) skip the continuity proof on empty takes, or (b) persist an empty-batch marker so the log tracks the counter. This implements (a)'s intent — an empty take does not participate in the continuity ledger — at the single place the divergence is created.

  • Literal (a), skipping the proof at checkpoint time, does not fix the defect: the take that fails the proof is the checkpoint take (usually non-empty), while the empty take that poisoned the counter happened earlier and is long gone. Suppressing the proof there would also gut the guard fix(daemon): restore previously recoverable scrollback from durable history #14193 added.
  • (b) was rejected on existing precedent. Both layers below already treat "empty" as "nothing happened": HistoryManager.appendIncrementsUntracked returns ok without writing when records.length === 0, and the adapter returns done on an empty take. Only session.ts disagreed. Making the log record no-op batches would contradict that rule and, because idle sessions are the common case, would spend the 5MB log cap and force checkpoint rotations on sessions producing zero output.

This also keeps the adapter's take.seq - 1 proof arithmetic unchanged, and incidentally repairs pendingRecordsAreComplete: take.seq === 1 — idle ticks before a session's first real output used to push the seq past 1 and defeat that check.

Remote wire compatibility. No wire shape change; seq is still a number on the same RPC. A mixed-version pairing is safe in both directions because an old adapter already early-returns on an empty take and never uses the seq it was handed, so a repeated value is never observed. Only what the host publishes for empty takes changes, and only in the direction that makes disk and memory agree.

Linked Issue

STA-4297 — https://linear.app/stably/issue/STA-4297

This is item 2 of the dropped durable-history unit. It is intended for the 1.4.184 pick set, not today's 1.4.183 cut, so a later daily can cherry-pick the durable-history unit as a complete stack.

Fixes #

Visual Proof

N/A — no UI or interaction surface. The change is a daemon-side counter condition; the observable effect (restore depth after reattach) is asserted by the automated test below rather than being a visual diff.

Testing

src/main/daemon/daemon-restore-scrollback-depth.test.ts — "preserves durable depth when an empty incremental take precedes a warm reattach". It asserts the outcome (recovered depth), not the counter: after the empty take plus a warm reattach it requires the restored history to still contain LINE_00001 and LINE_01000 and scrollbackLines to exceed the live window, so a fix that merely made the bookkeeping agree while still flattening the checkpoint would not pass.

Repro path in the test follows production, not internals: 5000 lines of output → deep durable checkpoint → adapter.write() marks the session dirty while the mock PTY echoes nothing back (a dirty mark with zero new records) → checkpointDirtySessions() runs the empty take → adapter crash + fresh adapter → warm reattach.

RED verified on unmodified origin/main (a6a64439a0), exact output:

FAIL  src/main/daemon/daemon-restore-scrollback-depth.test.ts > STA-4091 previously recoverable restore depth > adapter remount and restart > preserves durable depth when an empty incremental take precedes a warm reattach
AssertionError: expected 'LINE_03979\r\nLINE_03980\r\nLINE_0398…' to contain 'LINE_00001'
 ❯ src/main/daemon/daemon-restore-scrollback-depth.test.ts:523:33
 Test Files  1 failed (1)
      Tests  1 failed | 21 skipped (22)

LINE_03979 onward is the ~1000-row live window — the deep checkpoint was flattened, which is the defect.

Control run (to prove the RED is caused by the empty take and not incidental setup): removing only the two trigger lines, with every other line of the test identical, passes on unmodified main. Adding them back fails. So the empty incremental take is the sole cause.

The depth test is the sole regression oracle; no test asserts the counter directly. No matching entry exists in config/reliability-gates.jsonc, so that manifest coverage is an accepted gap for this focused patch; the existing [history] durable continuity unproven; using live snapshot: warning remains the diagnostic for genuine gaps. Live restore-depth reproduction on SSH, WSL, Linux, and Windows remains unverified; the changed arithmetic is provider- and platform-neutral, while CI covers cross-version compatibility and native smoke.

Checks run locally on macOS:

  • pnpm vitest run --config config/vitest.config.ts src/main/daemon → 1471 passed, 3 skipped (107 files). Includes the two sibling tests that must still lose depth (uses the live window when a crashed adapter left a pending-output gap, uses the live window when durable history disappears after a prior drain) — the fix does not weaken them.
  • pnpm vitest run --config config/vitest.config.ts src/main → 21423 passed, 90 skipped on the PR branch; 21422 passed, 90 skipped with all four scoped files restored exactly to origin/main (a6a64439a0). src/main/ipc/pty.test.ts separately passed 480/480 on both states. The previously reported 10 overlay-environment failures did not reproduce, so they are not claimed as pre-existing evidence.
  • pnpm run typecheck:node → clean.
  • oxlint (default + oxlint-code-quality-native-plugins + --type-aware oxlint-code-quality-type-aware, --deny-warnings) → clean. No max-lines disable added and no mobile/.oxlintrc.json bump; check:max-lines-ratchet reports no new bypasses.
  • oxfmt --check → clean (this repo formats with oxfmt, not prettier).

Platforms: the changed code is platform-neutral (an integer counter in the daemon session, no paths, no shell, no keybindings) and runs identically for local, SSH, and WSL hosts and for both git-worktree and folder workspaces. Verified on macOS; no platform-conditional behavior added.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Review

  • Security — no new surface; no input parsing, no IPC/RPC shape change, no filesystem paths.
  • Cross-platform — platform-neutral integer bookkeeping; no path, shell, or accelerator logic.
  • Remote SSH — covered above under Remote wire compatibility; safe in mixed-version pairings in both directions.
  • Mobile — unaffected; no mobile code touched.
  • Backwards compatibility — on-disk checkpoint and log formats unchanged, so existing histories are read exactly as before. The change only stops the counter from drifting ahead of them.
  • Performance — no I/O, allocation, scan, polling, or subprocess work is added; empty ticks skip one counter mutation. Opt-in 10 MiB session-ingest samples overlapped the base for both workload shapes (ASCII 68.3–80.5 vs 75.1–78.4 MB/s; TUI 110.4–112.1 vs 110.7–111.9 MB/s), with no regression signal.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Author

  • X / Twitter: @BrennanKB5

…tal takes (STA-4297)

An empty incremental take advanced pendingOutputSeq without writing a log
batch, so the in-memory counter ran permanently ahead of the log. The next
warm reattach could not prove continuity and committed the live 1000-row
window over a deep durable checkpoint.

Advance the counter only for takes that get persisted: a snapshot take
(stamped into the checkpoint) or one carrying records/overflow. This matches
the layers below, which already treat an empty take as a no-op write.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a9918deb-d39b-4425-a01e-12d657d33abf

📥 Commits

Reviewing files that changed from the base of the PR and between 3680887 and 4ec5db5.

📒 Files selected for processing (3)
  • src/main/daemon/daemon-restore-scrollback-depth.test.ts
  • src/main/daemon/session.ts
  • src/main/daemon/types.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/daemon/types.ts
  • src/main/daemon/session.ts
  • src/main/daemon/daemon-restore-scrollback-depth.test.ts

📝 Walkthrough

Walkthrough

takePendingOutput now keeps pendingOutputSeq unchanged for empty incremental takes. Snapshot, non-empty, and overflow takes still advance the sequence. The type documentation defines this non-decreasing behavior. A regression test verifies that durable scrollback survives an empty take, adapter crash, warm reattach, and subsequent compact.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the daemon sequence-tracking fix for empty incremental takes, which is the main change.
Description check ✅ Passed The description covers all template sections with clear rationale, testing evidence, issue context, and compatibility considerations.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/main/daemon/session.ts (1)

497-501: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce the new multi-line comments.

Keep one concise non-obvious reason where needed. Remove rationale that the code, test name, or assertions already show.

  • src/main/daemon/session.ts#L497-L501: Reduce this explanation to one brief reason for preserving pendingOutputSeq.
  • src/main/daemon/types.ts#L288-L292: Keep the public seq contract without the recovery-path explanation.
  • src/main/daemon/session-pending-output.test.ts#L99-L101: Remove this comment because the test name states the behavior.
  • src/main/daemon/daemon-restore-scrollback-depth.test.ts#L489-L491: Remove or reduce this comment because the test name and assertions state the outcome.

As per coding guidelines, “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d934a6a-99c2-4a9e-b183-cc932e79056e

📥 Commits

Reviewing files that changed from the base of the PR and between a6a6443 and 3680887.

📒 Files selected for processing (4)
  • src/main/daemon/daemon-restore-scrollback-depth.test.ts
  • src/main/daemon/session-pending-output.test.ts
  • src/main/daemon/session.ts
  • src/main/daemon/types.ts

@brennanb2025
brennanb2025 merged commit 6cd987e into main Aug 14, 2026
50 checks passed
julien-herrera pushed a commit to Oxeegen/OxeeUI that referenced this pull request Aug 19, 2026
…tal takes (STA-4297) (stablyai#14496)

* fix(daemon): persist the pending-output counter across empty incremental takes (STA-4297)

An empty incremental take advanced pendingOutputSeq without writing a log
batch, so the in-memory counter ran permanently ahead of the log. The next
warm reattach could not prove continuity and committed the live 1000-row
window over a deep durable checkpoint.

Advance the counter only for takes that get persisted: a snapshot take
(stamped into the checkpoint) or one carrying records/overflow. This matches
the layers below, which already treat an empty take as a no-op write.

* test(daemon): keep empty-take coverage outcome-based

(cherry picked from commit 6cd987e)
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.

1 participant