Skip to content

Bound recorded sessions with a safe global janitor (Fixes #3164) - #3201

Merged
acoliver merged 9 commits into
mainfrom
issue3164
Aug 10, 2026
Merged

Bound recorded sessions with a safe global janitor (Fixes #3164)#3201
acoliver merged 9 commits into
mainfrom
issue3164

Conversation

@acoliver

@acoliver acoliver commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

TLDR

Makes recording-session cleanup functional and safe for the JSONL format that the recorder actually writes. Cleanup now runs by default across the machine-global session corpus, bounds aggregate raw and compressed storage, preserves cold history in verified gzip archives, protects active or ambiguous sessions, and cleans stale locks through ownership-safe filesystem coordination.

Dive Deeper

  • Replaces the project-scoped legacy JSON cleanup path with a full sweep of canonical project-hash session directories under the global temp root.
  • Uses one bounded, BOM-aware session-start reader across replay, discovery, and cleanup so long or malformed first lines cannot drift between consumers or consume unbounded memory.
  • Defaults retention to enabled with a 4096 MiB aggregate raw-plus-archive budget and a one-day safety floor. Age and count limits remain optional and apply globally when configured.
  • Streams cold recordings into lossless gzip archives, verifies the archived bytes, preserves chronology, durably publishes the archive, and retains the raw source whenever ownership or filesystem state becomes ambiguous.
  • Adds a filesystem janitor lease and token-owned session locks with atomic publication, hard-link transition claims, stale PID-reuse handling, mutation-time identity checks, and conservative behavior around malformed or live locks.
  • Restricts mutations to recognized recording, archive, temporary-archive, lock, and empty managed-directory targets. Symlinks, unknown files, unreadable recordings, and out-of-root paths are retained.
  • Reports size, reclamation, shortfall, lock, scan, and failure diagnostics while isolating per-file filesystem failures so startup remains best-effort.
  • Removes the obsolete legacy session reader and replaces mock-heavy cleanup tests with real Bun filesystem, recorder, compression, subprocess, race, and CLI-boundary coverage.
  • Leaves telemetry-log retention settings unchanged because they belong to a separate subsystem.

Reviewer Test Plan

  1. Run the janitor, bounded-header, and session-lock Bun suites under packages/core/src/recording.
  2. Create recordings in more than one project-hash directory and verify one cleanup run discovers the full global corpus.
  3. Configure a small aggregate budget and verify eligible raw recordings become valid gzip archives before cold archives are evicted.
  4. Verify current, recent, live-locked, unreadable, symlinked, and concurrently replaced recordings remain untouched.
  5. Verify stale locks are removed while live or conservatively ambiguous locks are retained.
  6. Verify partial sessionRetention objects retain the default enabled state, size limit, and minimum-retention floor.
  7. Run lint, typecheck, format, build, and the stepfun-37 CLI smoke test.

Final local focused verification passed for the recording area, janitor, locking, CLI boundary, lint, typecheck, format, build, and smoke test. The final aggregate npm run test completed with status 1 because four unchanged agents files timed out at 180 seconds: agent.approvalMode.behavior.test.ts, createAgent.harness.behavior.test.ts, mutationCoverage.auth.behavior.test.ts, and mutationCoverage.tokens.behavior.test.ts. All remaining workspaces completed. A previous local aggregate run timed out in a different shifting set of unchanged agents files. On the final pushed commit, the CI agents shard and every other required CI job passed: 40 passed, 0 failed, and 3 intentionally skipped.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ aggregate agents timeouts; all issue-focused gates pass ✅ CI
npx
Docker ✅ CI E2E
Podman - -
Seatbelt - -

Linked issues / bugs

Fixes #3164

Summary by CodeRabbit

  • New Features

    • Added automatic session cleanup across the global temporary directory, enabled by default.
    • Added controls for total storage, session age, session count, and minimum retention duration.
    • Sessions can be compressed into archives before removal to help meet storage limits.
    • Cleanup protects active, locked, unreadable, and recently created sessions.
    • Added detailed cleanup results and diagnostics for skipped, archived, removed, or failed items.
  • Documentation

    • Expanded configuration guidance with available settings, defaults, and restart requirements.

Replace project-scoped legacy JSON cleanup with default-on machine-global JSONL retention. Preserve cold history in verified gzip archives while enforcing aggregate storage bounds and coordinating cleanup through ownership-safe leases and locks.
@github-actions github-actions Bot added the maintainer:e2e:ok Trusted contributor; maintainer-approved E2E run label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a core session-recording janitor with default-on retention, global scanning, bounded JSONL header parsing, archive compression, lease coordination, lock cleanup, and structured metrics. The CLI delegates cleanup to the janitor and exposes retention settings.

Changes

Session recording cleanup

Layer / File(s) Summary
Retention policy and discovery
packages/core/src/recording/janitor/*, packages/core/src/recording/boundedHeaderReader.ts, packages/core/src/recording/SessionDiscovery.ts
Adds retention validation, bounded JSONL header reading, session safety checks, global scanning, grouping, and eligibility evaluation.
Lock coordination
packages/core/src/recording/SessionLockManager.ts, packages/core/src/recording/SessionLockManager.internals.ts
Adds lazy lock loading, safe path validation, ownership checks, stale takeover, inode-bound claims, and orphan cleanup.
Archival and reclamation
packages/core/src/recording/janitor/archiveCompressor.ts, packages/core/src/recording/janitor/reclamationEngine.ts
Adds verified gzip archival and age, count, and physical-byte reclamation with revalidation and failure isolation.
Global orchestration and CLI wiring
packages/core/src/recording/janitor/sessionJanitor.ts, packages/cli/src/utils/sessionCleanup.ts, packages/cli/src/config/*, schemas/settings.schema.json, docs/cli/configuration.md
Acquires the global janitor lease, scans and cleans storage, removes empty directories and temporary artifacts, and returns structured metrics through the CLI boundary.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The OAuth toggle and quota-timeout test changes are unrelated to session cleanup and issue #3164. Remove the unrelated AuthDialog and interactive-run test changes, or link issues that justify those modifications.
Docstring Coverage ⚠️ Warning Docstring coverage is 73.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address the linked issue's JSONL parsing, default retention, global scanning, byte limits, stale locks, safety controls, and regression coverage.
Title check ✅ Passed The title clearly summarizes the main change: safe global cleanup for recorded sessions, and it references the fixed issue.
Description check ✅ Passed The description includes all required sections and provides clear scope, implementation details, testing steps, results, and linked issue information.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue3164

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

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Before this change, recorded-session maintenance was fragmented and unbounded: cleanup logic lived in ad-hoc CLI utilities, there was no global coordination for concurrent janitor work, and safety around active or in-progress sessions was limited. After this PR, session reclamation is centralized behind a safe global janitor. The core now provides bounded header reading, explicit session locking, lease-based ownership, retention policies, archive compression, and a reclamation engine, while the CLI cleanup path is rewritten to use those bounded primitives and respect the new safety checks. The result is a coordinated, safer cleanup flow that protects current sessions and bounds the work the janitor may perform at once.

Release Notes

New Features

  • Introduced a global janitor for recorded sessions with bounded reclamation, retention policies, archive compression, and explicit session safety/locking.
  • Added bounded header reading and session discovery to safely enumerate and process session files without unbounded work.
  • Added configurable session retention/cleanup behavior in CLI settings, including age/count-based retention and minimum retention enforcement.

Bug Fixes

  • Fixed unsafe or unbounded recorded-session cleanup behavior by routing cleanup through the new safe global janitor and session lock manager.

Tests

  • Added extensive janitor safety, boundary, integration, and reclamation tests across core and CLI cleanup utilities.
  • Added session lock manager property, lazy-loading, and safety tests, plus bounded header-reader coverage.

Documentation

  • Updated CLI configuration documentation to describe the new session retention and cleanup settings.

Refactor

  • Split janitor responsibilities into modular components: lease management, retention policy, session scanning, session safety, archive compression, and reclamation engine.
  • Refactored CLI session cleanup utilities to use the new bounded core janitor APIs instead of standalone ad-hoc cleanup logic.

Chore

  • Updated settings schema and core package metadata to reflect the new janitor and session-retention configuration surface.

Changes

Layer File(s) Summary
packages/core/src/recording/janitor packages/core/src/recording/janitor/sessionScanner.test.ts, packages/core/src/recording/janitor/sessionHeaderReader.test.ts, packages/core/src/recording/janitor/janitorLease.ts, packages/core/src/recording/janitor/retentionPolicy.ts, packages/core/src/recording/janitor/janitorLease.test.ts, packages/core/src/recording/janitor/archiveCompressor.ts, packages/core/src/recording/janitor/sessionSafety.ts, packages/core/src/recording/janitor/janitorLease.safety.test.ts, packages/core/src/recording/janitor/index.ts, packages/core/src/recording/janitor/archiveCompressor.safety.test.ts, packages/core/src/recording/janitor/sessionHeaderReader.ts, packages/core/src/recording/janitor/sessionJanitor.test.ts, packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts, packages/core/src/recording/janitor/reclamationEngine.ts, packages/core/src/recording/janitor/sessionScanner.ts, packages/core/src/recording/janitor/retentionPolicy.test.ts, packages/core/src/recording/janitor/sessionJanitor.ts, packages/core/src/recording/janitor/archiveCompressor.test.ts, packages/core/src/recording/janitor/sessionGrouping.ts, packages/core/src/recording/janitor/sessionSafety.test.ts, packages/core/src/recording/janitor/sessionJanitor.safety.test.ts, packages/core/src/recording/janitor/cleanupTypes.ts Changes in packages/core/src/recording/janitor
packages/cli/src/ui/components packages/cli/src/ui/components/AuthDialog.test.tsx Changes in packages/cli/src/ui/components
packages/core/src/recording packages/core/src/recording/SessionLockManager.property.test.ts, packages/core/src/recording/SessionLockManager.lazy.test.ts, packages/core/src/recording/SessionLockManager.test.ts, packages/core/src/recording/boundedHeaderReader.test.ts, packages/core/src/recording/ReplayEngine.ts, packages/core/src/recording/SessionLockManager.safety.test.ts, packages/core/src/recording/boundedHeaderReader.ts, packages/core/src/recording/SessionJanitor.ts, packages/core/src/recording/SessionLockManager.ts, packages/core/src/recording/SessionDiscovery.ts, packages/core/src/recording/SessionLockManager.internals.ts, packages/core/src/recording/index.ts Changes in packages/core/src/recording
packages/cli/src/config/settings-schema packages/cli/src/config/settings-schema/schema-core.ts Changes in packages/cli/src/config/settings-schema
packages/cli/src/utils packages/cli/src/utils/sessionCleanup.test.ts, packages/cli/src/utils/sessionUtils.ts, packages/cli/src/utils/sessionCleanup-test-helpers.ts, packages/cli/src/utils/sessionCleanup.ts, packages/cli/src/utils/sessionCleanup.config.test.ts, packages/cli/src/utils/sessionCleanup.boundary.test.ts, packages/cli/src/utils/sessionCleanup.integration.test.ts Changes in packages/cli/src/utils
packages/core packages/core/package.json Changes in packages/core
schemas schemas/settings.schema.json Changes in schemas
packages/test-utils/src packages/test-utils/src/interactive-run.test.ts Changes in packages/test-utils/src
packages/cli/src/config packages/cli/src/config/settings.ts Changes in packages/cli/src/config
project-plans/issue3164 project-plans/issue3164/plan.md Changes in project-plans/issue3164
docs/cli docs/cli/configuration.md Changes in docs/cli

Sequence Diagram

sequenceDiagram
  participant Settings as CLI Settings
  participant Cleanup as sessionCleanup
  participant SessionUtils as sessionUtils
  participant Recording as recording layer
  participant LockManager as SessionLockManager
  Settings->>Cleanup: load merged sessionRetention config
  Cleanup->>Cleanup: validate maxAge, maxCount, minRetention
  Cleanup->>SessionUtils: getAllSessionFiles(chatsDir, currentSessionId)
  SessionUtils-->>Cleanup: session file entries with parsed or null sessionInfo
  Cleanup->>Cleanup: identify corrupted and expired deletable sessions
  Cleanup->>SessionUtils: delete session JSONL files by retention policy
  Recording->>Recording: bounded header/session reads for discovery and replay
  Recording->>LockManager: acquire, stale-check, and cleanup orphaned session locks
Loading

Magnitude

🎯 4 (XL)
11802 additions, 2725 deletions, 49 changed files across 3 packages, 0 acceptance criteria

Related

Pre-merge Checks

Check Status Note
Title Clear and descriptive; accurately conveys the global session janitor scope and includes the fixed issue reference.
Description Contains all required template sections: TLDR, Dive Deeper, Reviewer Test Plan, Testing Matrix, and Linked issues/bugs.
Linked Issues Fulfills issue #3164 acceptance criteria: replaces legacy .json reader with canonical JSONL discovery/parsing, enables retention by default with a 4 GiB aggregate byte budget, implements a global sweep of 64-hex project-hash directories, integrates stale-lock cleanup with PID-reuse safety, and bounds reclamation by actual bytes with lossless gzip archival. Cross-process lease safety and blast-radius constraints are implemented. Telemetry retention knobs are intentionally deferred to a separate subsystem.
Out of Scope Telemetry retention settings (retentionDays/maxLogFiles/maxLogSizeMB) are explicitly left unchanged, as noted in the PR description. No other out-of-scope items are evident from the supplied changes.

Walkthrough generated by LLxprt PR Review. Planner issue: #2256

@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.

Actionable comments posted: 16

🧹 Nitpick comments (23)
packages/core/src/recording/SessionLockManager.safety.test.ts (1)

381-430: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Report a timeout distinctly in runBunScript.

When the timer fires, the helper sends SIGTERM. The close handler then receives a null exit code and rejects with Process exited with code null. The message does not state that the timeout caused the termination. Set a timedOut flag in the timer and include it in the rejection message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/SessionLockManager.safety.test.ts` around lines
381 - 430, Update runBunScript to track whether the timeout callback terminated
the child by setting a timedOut flag before sending SIGTERM, then have the
close-handler rejection message explicitly report the timeout when that flag is
set while preserving the existing exit-code and stderr details otherwise.
packages/core/src/recording/SessionLockManager.internals.ts (1)

199-222: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Confirm the temp-write failure path cannot be read as "lock exists".

tryCreateLock returns false when the second writeTempLockFile call also returns false. acquire then enters tryStaleTakeover, which treats the situation as "lock already exists". writeTempLockFile returns false only for EEXIST and ENOENT, so the case is narrow, but an ENOENT that persists after mkdir (for example, a concurrently removed chatsDir) is reported as lock contention rather than an I/O failure. Consider distinguishing the two outcomes so the caller can propagate a real I/O error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/SessionLockManager.internals.ts` around lines 199
- 222, Update tryCreateLock and its acquire caller so a persistent temp-file
write failure caused by ENOENT is propagated as an I/O error rather than
returned as lock contention, while preserving false for genuine EEXIST
contention. Ensure acquire only invokes tryStaleTakeover when the lock actually
exists, and retain cleanup of the temporary path on failure.
packages/core/src/recording/SessionLockManager.lazy.test.ts (1)

46-89: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

runBunScript duplicates the helper in SessionLockManager.safety.test.ts.

Both files define the same spawn, timeout, and settle logic. Extract one helper into a shared test-utility module and import it in both files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/SessionLockManager.lazy.test.ts` around lines 46
- 89, Extract the shared spawn, timeout, cleanup, and settlement logic from
runBunScript into a common test-utility module, then import and reuse that
helper in both SessionLockManager.lazy.test.ts and
SessionLockManager.safety.test.ts. Preserve the existing timeout, stdout
handling, error reporting, and Promise settlement behavior.
packages/core/src/recording/SessionLockManager.property.test.ts (1)

827-873: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

These itProp cases use fixed inputs.

None of the three cases declares arbitraries, so each case repeats the same fixed scenario for every property run. Either use it for these deterministic cases, or generate the age so the property covers both sides of the 48-hour bound. A generated age also proves the boundary rather than one point at 49 hours.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/SessionLockManager.property.test.ts` around lines
827 - 873, Replace the three deterministic itProp cases around
checkStaleWithPidReuse with it, or add an age arbitrary that exercises both
sides of the 48-hour threshold. Prefer generated ages if retaining property
tests, and assert stale only when the generated age exceeds the bound while
recent locks remain non-stale.
packages/core/src/recording/SessionLockManager.ts (1)

56-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize the safe path-validation helpers.

SessionLockManager.ts and janitor/sessionSafety.ts duplicate isValidSafeSessionId and isDirectChildPath, including the 256-character limit. Move them to a dependency-free module, or add a test that compares both implementations to prevent drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/SessionLockManager.ts` around lines 56 - 67,
Centralize SAFE_ID_MAX_LENGTH, SAFE_SESSION_ID_RE, isValidSafeSessionId, and
isDirectChildPath in a dependency-free shared module, then update
SessionLockManager and janitor/sessionSafety.ts to reuse those helpers instead
of duplicating them. Preserve the existing validation behavior and avoid
introducing filesystem dependencies into the shared module.
packages/core/src/recording/janitor/sessionGrouping.ts (1)

86-95: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a byte-wise comparison for the deterministic tie-break.

localeCompare depends on the active locale and the ICU build. Two machines can order the same two paths differently, which contradicts the "Deterministic" claim in the doc comment. Ordering drives which sessions are evicted first, so the result must not depend on the environment.

♻️ Proposed change
   const mtimeDiff = a.mtime.getTime() - b.mtime.getTime();
   if (mtimeDiff !== 0) return mtimeDiff;
-  return normalizedPath(representativePath(a)).localeCompare(
-    normalizedPath(representativePath(b)),
-  );
+  const pathA = normalizedPath(representativePath(a));
+  const pathB = normalizedPath(representativePath(b));
+  if (pathA < pathB) return -1;
+  if (pathA > pathB) return 1;
+  return 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionGrouping.ts` around lines 86 - 95,
Update compareGroupsOldestFirst to replace localeCompare in the normalized
representative-path tie-break with a locale-independent byte-wise comparison,
while preserving the mtime ordering and deterministic handling of equal paths.
packages/core/src/recording/boundedHeaderReader.test.ts (2)

159-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Import the chunk size instead of duplicating it.

Line 164 hardcodes 64 * 1024. If the reader changes its chunk size, this test no longer places the multi-byte character on a chunk boundary, and the regression it guards stops being covered. Export the chunk size from boundedHeaderReader.ts and import it here.

♻️ Proposed change
-    const READ_CHUNK_SIZE = 64 * 1024;
-    const before = 'a'.repeat(READ_CHUNK_SIZE - 1);
+    const before = 'a'.repeat(BOUNDED_HEADER_READ_CHUNK_SIZE - 1);

Add the export in packages/core/src/recording/boundedHeaderReader.ts:

export const BOUNDED_HEADER_READ_CHUNK_SIZE = 64 * 1024;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/boundedHeaderReader.test.ts` around lines 159 -
173, Export the reader’s chunk-size constant from boundedHeaderReader.ts as
BOUNDED_HEADER_READ_CHUNK_SIZE, then import and use it in the test’s boundary
setup instead of duplicating 64 * 1024. Update the before-string calculation to
reference this shared constant so the multi-byte boundary coverage follows
reader changes.

132-157: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Document the exclusive byte boundary.

readBoundedFirstLine inspects at most BOUNDED_HEADER_MAX_BYTES bytes. The newline must occur before byte BOUNDED_HEADER_MAX_BYTES; MAX - 1 content bytes plus LF succeeds, while MAX content bytes plus LF returns null. State this exact-boundary rule in the reader doc comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/boundedHeaderReader.test.ts` around lines 132 -
157, Update the doc comment for readBoundedFirstLine to explicitly state the
exclusive byte-boundary rule: it reads at most BOUNDED_HEADER_MAX_BYTES bytes,
and the newline must occur before byte BOUNDED_HEADER_MAX_BYTES. Document that
MAX - 1 content bytes plus LF succeeds, while MAX content bytes plus LF returns
null.
packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts (2)

587-618: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The inline comment does not match the implementation.

The comment states "each ~4 KB allocated". sessionScanner and evictArchivesForBudget use stat().size, which is the logical byte size, not the allocated block size. Correct the comment so a later reader does not tune the budget against block allocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts`
around lines 587 - 618, Update the inline budget comment in the equal-mtime test
to describe the archives by their logical byte size, as measured by stat().size,
rather than allocated filesystem blocks. Keep the budget explanation consistent
with the actual size-based behavior of sessionScanner and
evictArchivesForBudget.

38-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared test helpers.

makeTempDir, validHash64, makeConfig, createSession, and fileExists are duplicated almost verbatim in packages/core/src/recording/janitor/sessionJanitor.safety.test.ts Lines 51-110. The two copies of createSession already differ: the safety copy wraps flush in try/finally around dispose, this copy does not. Move the helpers into a shared test-support module so the behavior stays identical.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts`
around lines 38 - 118, Extract makeTempDir, validHash64, makeConfig,
createSession, and fileExists into a shared test-support module, then import and
reuse them from both sessionJanitor.reclamation.test.ts and
sessionJanitor.safety.test.ts. Preserve the safety test’s try/finally
flush-and-dispose behavior in the shared createSession implementation, and leave
reclamation-specific makeArchive and listArchives local.
packages/core/src/recording/janitor/reclamationEngine.ts (1)

83-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The unlink fault seam ships in production code.

unlinkFaultFn is module-global mutable state, and setUnlinkFaultForTest is a public export. Any consumer of @vybestack/llxprt-code-core can replace the unlink implementation for the janitor. Consider excluding this export from the package barrel (packages/core/src/recording/janitor/index.ts) so it stays reachable only through a direct module import in tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/reclamationEngine.ts` around lines 83 -
105, Exclude setUnlinkFaultForTest from the janitor package barrel in
packages/core/src/recording/janitor/index.ts so it is not exposed through the
public package API. Keep the function available via direct module imports for
tests, while preserving the existing platformUnlink behavior.
packages/core/src/recording/janitor/archiveCompressor.ts (1)

52-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Couple the temp grammar to the generated temp name.

cleanupStaleTempArchives only removes files that match ^session-.+\.gz\.tmp$. The temp name at Line 224 is path.basename(sourcePath) + '.' + uuid + TEMP_ARCHIVE_SUFFIX. If a source basename does not start with session-, the temp artifact never matches the grammar, so a crashed compression leaves an orphan file forever. Add a fixed janitor prefix to the temp name so the grammar always matches, independent of the source basename.

♻️ Proposed change
-const TEMP_ARCHIVE_GRAMMAR = /^session-.+\.gz\.tmp$/;
+const TEMP_ARCHIVE_PREFIX = 'session-janitor-';
+const TEMP_ARCHIVE_GRAMMAR = /^session-.+\.gz\.tmp$/;
-  const tempName = sourceBase + '.' + crypto.randomUUID() + TEMP_ARCHIVE_SUFFIX;
+  const tempName =
+    TEMP_ARCHIVE_PREFIX +
+    sourceBase +
+    '.' +
+    crypto.randomUUID() +
+    TEMP_ARCHIVE_SUFFIX;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/archiveCompressor.ts` around lines 52 -
55, Update the temp archive name construction in the compression flow to prepend
the fixed `session-` janitor prefix before the source basename, UUID, and
`TEMP_ARCHIVE_SUFFIX`. Keep `TEMP_ARCHIVE_GRAMMAR` unchanged so every generated
temporary artifact matches `cleanupStaleTempArchives`, regardless of the source
filename.
packages/core/src/recording/janitor/archiveCompressor.test.ts (1)

243-254: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Gate these symlink tests on non-Windows platforms.

Both tests call fs.symlink, which requires elevated privileges on Windows. Use it.skipIf(process.platform === 'win32'). This is the same root cause as the symlink tests in packages/core/src/recording/janitor/archiveCompressor.safety.test.ts.

Based on learnings: "prefer platform-gating via it.skipIf(process.platform === 'win32') instead of doing an early return inside the test body".

Also applies to: 268-285

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/archiveCompressor.test.ts` around lines
243 - 254, Gate both symlink-related tests in the archive compressor test suite
with it.skipIf(process.platform === 'win32'), including the test around
compressToArchive and the additional test at the referenced range. Apply the
gate directly to each it declaration and remove or avoid any in-test
early-return platform checks.

Source: Learnings

packages/core/src/recording/janitor/sessionJanitor.safety.test.ts (1)

150-182: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Gate these symlink tests on non-Windows platforms.

Both tests call fs.symlink, which requires elevated privileges on Windows. Use it.skipIf(process.platform === 'win32').

Based on learnings: "prefer platform-gating via it.skipIf(process.platform === 'win32') instead of doing an early return inside the test body".

Also applies to: 196-229

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.safety.test.ts` around
lines 150 - 182, Gate both symlink safety tests, including the test around
runSessionCleanup and the additional test at the referenced section, with
it.skipIf(process.platform === 'win32') so they are skipped on Windows before
execution. Remove any equivalent early-return approach if present and leave the
test assertions unchanged.

Source: Learnings

packages/core/src/recording/janitor/archiveCompressor.safety.test.ts (1)

61-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Symlink-based tests are not portable to Windows. Every one of these tests calls fs.symlink, which requires elevated privileges or Developer Mode on Windows, so each fails for a reason unrelated to the code under test. Apply it.skipIf(process.platform === 'win32') at each site.

  • packages/core/src/recording/janitor/archiveCompressor.safety.test.ts#L61-L92: gate the symlinked archive directory and symlinked source tests.
  • packages/core/src/recording/janitor/archiveCompressor.safety.test.ts#L159-L180: gate the symlinked temp file lstat test.
  • packages/core/src/recording/janitor/archiveCompressor.test.ts#L243-L254: gate the symlinked source source-invalid test.
  • packages/core/src/recording/janitor/archiveCompressor.test.ts#L268-L285: gate the symlinked existing archive test.
  • packages/core/src/recording/janitor/sessionJanitor.safety.test.ts#L150-L182: gate the symlinked archive directory sweep test.
  • packages/core/src/recording/janitor/sessionJanitor.safety.test.ts#L196-L229: gate the scan-to-mutation symlink replacement test.

Based on learnings: "For TypeScript unit/integration tests that intentionally skip behavior on Windows, prefer platform-gating via it.skipIf(process.platform === 'win32')".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/archiveCompressor.safety.test.ts` around
lines 61 - 92, Symlink-dependent tests must be skipped on Windows. Apply
it.skipIf(process.platform === 'win32') to the two tests in
packages/core/src/recording/janitor/archiveCompressor.safety.test.ts#L61-L92,
the lstat symlink test at
packages/core/src/recording/janitor/archiveCompressor.safety.test.ts#L159-L180,
the source-invalid and existing-archive symlink tests in
packages/core/src/recording/janitor/archiveCompressor.test.ts#L243-L254 and
`#L268-L285`, and both symlink tests in
packages/core/src/recording/janitor/sessionJanitor.safety.test.ts#L150-L182 and
`#L196-L229`.

Source: Learnings

packages/core/src/recording/janitor/janitorLease.ts (3)

136-148: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Close the file handle before unlinking the temp file.

The catch block calls safeUnlink(tempPath) while fd is still open. The finally block closes fd afterwards. On Windows, unlink on an open handle fails, so the temp file remains on disk after a write failure.

Move the unlink after the close.

♻️ Proposed reordering
     let fd: fsp.FileHandle | undefined;
+    let createFailed = false;
     try {
       fd = await fsp.open(tempPath, 'wx');
       await fd.writeFile(JSON.stringify(record), 'utf-8');
       await fd.sync();
     } catch (error: unknown) {
-      await safeUnlink(tempPath);
-      // EEXIST (uuid collision) is the only benign retryable case.
-      if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false;
-      throw error; // Propagate genuine I/O failures.
+      createFailed = true;
+      await fd?.close().catch(() => {});
+      fd = undefined;
+      await safeUnlink(tempPath);
+      // EEXIST (uuid collision) is the only benign retryable case.
+      if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false;
+      throw error; // Propagate genuine I/O failures.
     } finally {
-      await fd?.close().catch(() => {});
+      if (!createFailed) await fd?.close().catch(() => {});
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/janitorLease.ts` around lines 136 - 148,
Reorder the error cleanup in the file-writing flow so the handle opened by
fsp.open is closed before safeUnlink(tempPath) runs. Update the
try/catch/finally logic around the visible fd variable while preserving EEXIST’s
false return and propagation of other errors.

85-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The heartbeat state is per class, not per lease.

heartbeatTimer and inFlightHeartbeat are static. startHeartbeat calls stopHeartbeat first. If one process acquires two leases for two different globalTempDir values, the second acquisition stops the heartbeat of the first lease. The first lease then ages out and another process can take it over while it is still in use.

The janitor currently uses one global temp root, so this is latent. Consider binding the timer to the handle returned by makeHandle.

Also applies to: 251-262

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/janitorLease.ts` around lines 85 - 86,
Move heartbeat state out of the static class fields and bind each timer and
in-flight heartbeat to the lease handle created by makeHandle. Update
startHeartbeat and stopHeartbeat to operate on that handle’s state so acquiring
a second lease cannot stop the first lease’s heartbeat, while preserving cleanup
for each individual lease.

401-414: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Do not release a claim that was never acquired.

acquireTransitionClaim returns true for ENOENT without creating the claim link. The caller then always calls releaseTransitionClaim, which unlinks <leasePath>.tclaim. If another process creates the lease and its claim in that window, this unlink removes the other process's claim link. The other process then fails verifyTransitionClaim and skips its heartbeat or release, which can leave a lease file behind.

Track whether the claim was actually created and release only in that case.

♻️ Sketch: return an explicit claim state
-  private static async acquireTransitionClaim(
-    leasePath: string,
-  ): Promise<boolean> {
+  private static async acquireTransitionClaim(
+    leasePath: string,
+  ): Promise<'claimed' | 'no-lease' | 'busy'> {
     const claimPath = JanitorLease.getClaimPath(leasePath);
     try {
       await fsp.link(leasePath, claimPath);
-      return true; // Claimed the lease inode.
+      return 'claimed';
     } catch (error: unknown) {
       const code = (error as NodeJS.ErrnoException).code;
-      if (code === 'ENOENT') return true; // No lease — no claim needed.
-      if (code !== 'EEXIST') return false;
+      if (code === 'ENOENT') return 'no-lease';
+      if (code !== 'EEXIST') return 'busy';
     }
-    return JanitorLease.tryReclaimClaim(leasePath);
+    return (await JanitorLease.tryReclaimClaim(leasePath)) ? 'claimed' : 'busy';
   }

Callers then skip releaseTransitionClaim when the result is 'no-lease'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/janitorLease.ts` around lines 401 - 414,
Update acquireTransitionClaim to return an explicit state distinguishing an
actually created claim from the ENOENT no-lease case, while preserving
contention handling through tryReclaimClaim. In the caller of
acquireTransitionClaim, invoke releaseTransitionClaim only when the state
confirms this process created the claim, and skip release for the no-lease
result.
schemas/settings.schema.json (1)

254-272: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add numeric bounds so editors reject invalid retention values.

resolveRetentionConfig throws when maxTotalSizeMB is not positive. The schema accepts any number, so editors accept 0 or a negative value and the failure appears only at runtime. maxCount has the same gap.

♻️ Proposed constraints
           "default": 4096,
-          "type": "number"
+          "type": "number",
+          "exclusiveMinimum": 0
         },
@@
           "markdownDescription": "Maximum number of sessions to keep (most recent). No default count limit.\n\n- Category: `General`\n- Requires restart: `no`",
-          "type": "number"
+          "type": "number",
+          "minimum": 0,
+          "multipleOf": 1
         },

Note: this file appears generated from packages/cli/src/config/settings-schema/schema-core.ts. Apply the change at the source definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@schemas/settings.schema.json` around lines 254 - 272, Update the source
schema definition for the retention settings, not the generated JSON, to add
positive numeric bounds for maxTotalSizeMB and maxCount. Use the existing schema
constraints conventions so editors reject zero and negative values while
preserving their current defaults and descriptions.
packages/core/src/recording/janitor/janitorLease.safety.test.ts (2)

202-209: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op utimes on the temp directory.

Line 204 sets the mtime of tempDir. checkLeaseStaleness reads the mtime of the lease file, not of the directory. The following writeFile also resets the directory mtime. Line 209 already sets the required file mtime.

♻️ Proposed cleanup
     // Write a corrupt lease file (old enough to be past the age bound).
-    const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
-    await fs.utimes(tempDir, new Date(oldTime), new Date(oldTime));
     await fs.writeFile(leasePath, 'this is corrupt garbage!!!');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/janitorLease.safety.test.ts` around lines
202 - 209, Remove the unnecessary fs.utimes call targeting tempDir in the
corrupt lease setup, leaving the leasePath mtime update as the sole age-setting
operation before checkLeaseStaleness is exercised.

435-494: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Terminate the child process when the test fails.

The test spawns bun but never kills the child. If either wait rejects on timeout, the test fails and the child keeps running for the rest of its sleep. The afterEach then removes tempDir while the child still writes to it. janitorLease.test.ts uses a runBunScript helper that kills the child; reuse that pattern here.

♻️ Proposed guard
     const child = spawn('bun', ['-e', script], {
       stdio: ['pipe', 'pipe', 'pipe'],
       env: { ...process.env, TEST_TEMP_DIR: tempDir },
     });
+    let childExited = false;
+    child.on('close', () => (childExited = true));
+    try {
+      // ... existing waits and assertions ...
+    } finally {
+      if (!childExited) child.kill('SIGTERM');
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/janitorLease.safety.test.ts` around lines
435 - 494, Ensure the subprocess spawned in the lease test is terminated
whenever the test fails or either wait rejects, using the cleanup pattern from
runBunScript in janitorLease.test.ts. Wrap the spawn-and-wait flow with
guaranteed child cleanup, while preserving normal release handling and
preventing afterEach from removing tempDir while the child remains active.
packages/core/src/recording/janitor/sessionJanitor.test.ts (1)

43-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Import crypto explicitly.

validHash64 and makeConfig use the global crypto. This depends on the TypeScript lib configuration exposing Crypto in a non-DOM package. An explicit node:crypto import removes that dependence and matches the other files in this directory.

♻️ Proposed import
 import * as path from 'node:path';
+import * as crypto from 'node:crypto';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.test.ts` around lines 43 -
50, Import the Node crypto module explicitly in sessionJanitor.test.ts and
update validHash64 and makeConfig to use that imported crypto reference instead
of the global. Match the existing node:crypto import style used by neighboring
files.
packages/core/src/recording/janitor/sessionJanitor.ts (1)

341-353: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

The final rescan doubles the scan cost of every sweep.

buildFinalResult runs a second full scanGlobalSessions over all project-hash directories. The sweep already knows bytesBefore and the reclaimed byte counts. On large recording roots this second walk adds significant readdir/stat I/O at startup.

If exact post-state bytes are not required, derive bytesAfter from bytesBefore minus reclaimed bytes, and keep the rescan behind an option.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.ts` around lines 341 -
353, Update buildFinalResult to avoid unconditionally calling scanGlobalSessions
for the final state. Derive bytesAfter from bytesBefore minus the bytes
successfully reclaimed during the sweep, and only perform the final rescan when
the existing or added option explicitly requests exact post-state bytes;
preserve rescan error accounting when that option is enabled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/recording/boundedHeaderReader.ts`:
- Around line 99-109: Update the decoding flow around StringDecoder.write and
bomStripped so bomStripped is set to true only when decoded text exists. Keep it
false when decoder.write returns an empty string for an incomplete BOM, allowing
the BOM to be removed once the sequence completes before JSON parsing.

In `@packages/core/src/recording/janitor/archiveCompressor.test.ts`:
- Around line 321-344: Make the test’s unreadable-source setup in “returns typed
source-invalid error when source becomes unreadable during reuse check” work
under root by skipping when process.getuid() is 0 or replacing the chmod-based
failure with a privilege-independent unreadable path such as a directory.
Preserve the assertions for the source-invalid result and restore any modified
filesystem state in the existing cleanup.

In `@packages/core/src/recording/janitor/archiveCompressor.ts`:
- Around line 193-215: Move the existing archiveDir identity validation using
isRegularNonSymlinkDir and pathExists above the tryReuseExistingArchive call.
Ensure symlink or non-directory paths return archiveError before any reuse
logic, while preserving the existing reuse and mkdir behavior for valid
directories.

In `@packages/core/src/recording/janitor/janitorLease.test.ts`:
- Around line 118-133: Gate the “tryAcquire propagates I/O errors” test using
it.skipIf so it runs only on non-root POSIX environments, excluding Windows and
processes running with root privileges. Keep the existing chmod, assertion, and
cleanup logic unchanged for supported environments.
- Line 282: Move the 30000ms timeout from the enclosing describe configuration
to the affected it test so bun:test applies it. Preserve the test body and suite
structure, changing only the timeout placement.

In `@packages/core/src/recording/janitor/reclamationEngine.ts`:
- Around line 570-582: Update evictArchivesForBudget after the successful
scanGlobalSessions call to replace state.totalBytes with the fresh scan’s
authoritative aggregate, then re-check the maxTotalSizeBytes budget before
constructing the evictable list. Preserve the existing scan-error accounting and
early return behavior.
- Around line 357-371: Reconcile the running size estimate in
packages/core/src/recording/janitor/reclamationEngine.ts#L357-L371 by avoiding
addition of outcome.archiveBytes when compressToArchive reused an archive
already counted by the scan; expose reuse state through ArchiveResult or
subtract the previously counted archive size. At
packages/core/src/recording/janitor/reclamationEngine.ts#L570-L582, assign
state.totalBytes from the freshScan aggregate after rescanning, then re-check
the budget before constructing the evictable list.
- Around line 492-500: Update the compression-failure branch after
compressToArchive in the reclamation flow to set failed based on
result.errorKind, counting platform failures while keeping protective refusals
uncounted. Preserve the existing archived, rawDeleted, and archiveBytes values.

In `@packages/core/src/recording/janitor/sessionJanitor.safety.test.ts`:
- Around line 13-14: Update the license header in sessionJanitor.safety.test.ts
to use the complete Apache-2.0 wording, including “permissions and” between
“governing” and “limitations.”

In `@packages/core/src/recording/janitor/sessionJanitor.ts`:
- Around line 181-194: Update the CLI startup cleanup flow around
cleanupExpiredSessions so retention configuration resolution is covered by the
existing try/catch boundary. Ensure errors from resolveRetentionConfig,
including through runSessionCleanupWithSettings, are caught and treated as
best-effort cleanup failures rather than propagating from the cli.tsx startup
await.

In `@packages/core/src/recording/janitor/sessionScanner.test.ts`:
- Around line 360-377: Gate the EACCES test and the symlink tests in this
describe block with it.skipIf(process.platform === 'win32') and a root-privilege
check, so they do not run where chmod or symlink creation cannot enforce the
expected failure. Apply the gating to the test declarations, preserving the
existing test bodies and assertions for supported non-root environments.

In `@packages/core/src/recording/janitor/sessionScanner.ts`:
- Around line 207-220: Update the isCurrentSession assignment in the
SessionCandidate construction to return true only when currentSessionId is
defined and equals the parsed header sessionId; preserve false when either value
is undefined.

In `@packages/core/src/recording/SessionLockManager.internals.ts`:
- Around line 391-398: Apply the same managed-target validation used by
temp-artifact cleanup to the orphaned guard sweep around guardFiles: only
process names matching the exact <safeSessionId>.lock.tguard grammar, and
require each path to be a direct child of chatsDir, a regular file, and not a
symlink before calling checkStaleWithPidReuse or safeUnlink.

In `@packages/core/src/recording/SessionLockManager.safety.test.ts`:
- Around line 446-496: Update the stale-takeover race test around the subprocess
script and runBunScript calls to synchronize child startup with an explicit
readiness barrier. Have each of the three children create a unique ready file
and wait until all three ready files exist before calling
SessionLockManager.acquire; apply the same barrier and timing-independent
coordination to the second race test with the 500 ms hold. Preserve the
single-winner assertions and existing lock behavior.

In `@packages/core/src/recording/SessionLockManager.test.ts`:
- Around line 366-386: Gate the read-only-directory test around
SessionLockManager.acquire with it.skipIf so it is skipped on Windows and when
process.getuid() indicates root privileges. Preserve the existing I/O-error
assertions and cleanup, using the repository’s skipIf convention rather than an
early return inside the test.

In `@packages/core/src/recording/SessionLockManager.ts`:
- Around line 78-94: Update isDirectChildPath to remove any trailing path
separator from the normalized parent before constructing parentWithSep, while
preserving the existing direct-child validation and rejection of the parent
itself or nested paths. Ensure assertSafeLockPath accepts valid lock paths when
chatsDir is provided with a trailing separator.

---

Nitpick comments:
In `@packages/core/src/recording/boundedHeaderReader.test.ts`:
- Around line 159-173: Export the reader’s chunk-size constant from
boundedHeaderReader.ts as BOUNDED_HEADER_READ_CHUNK_SIZE, then import and use it
in the test’s boundary setup instead of duplicating 64 * 1024. Update the
before-string calculation to reference this shared constant so the multi-byte
boundary coverage follows reader changes.
- Around line 132-157: Update the doc comment for readBoundedFirstLine to
explicitly state the exclusive byte-boundary rule: it reads at most
BOUNDED_HEADER_MAX_BYTES bytes, and the newline must occur before byte
BOUNDED_HEADER_MAX_BYTES. Document that MAX - 1 content bytes plus LF succeeds,
while MAX content bytes plus LF returns null.

In `@packages/core/src/recording/janitor/archiveCompressor.safety.test.ts`:
- Around line 61-92: Symlink-dependent tests must be skipped on Windows. Apply
it.skipIf(process.platform === 'win32') to the two tests in
packages/core/src/recording/janitor/archiveCompressor.safety.test.ts#L61-L92,
the lstat symlink test at
packages/core/src/recording/janitor/archiveCompressor.safety.test.ts#L159-L180,
the source-invalid and existing-archive symlink tests in
packages/core/src/recording/janitor/archiveCompressor.test.ts#L243-L254 and
`#L268-L285`, and both symlink tests in
packages/core/src/recording/janitor/sessionJanitor.safety.test.ts#L150-L182 and
`#L196-L229`.

In `@packages/core/src/recording/janitor/archiveCompressor.test.ts`:
- Around line 243-254: Gate both symlink-related tests in the archive compressor
test suite with it.skipIf(process.platform === 'win32'), including the test
around compressToArchive and the additional test at the referenced range. Apply
the gate directly to each it declaration and remove or avoid any in-test
early-return platform checks.

In `@packages/core/src/recording/janitor/archiveCompressor.ts`:
- Around line 52-55: Update the temp archive name construction in the
compression flow to prepend the fixed `session-` janitor prefix before the
source basename, UUID, and `TEMP_ARCHIVE_SUFFIX`. Keep `TEMP_ARCHIVE_GRAMMAR`
unchanged so every generated temporary artifact matches
`cleanupStaleTempArchives`, regardless of the source filename.

In `@packages/core/src/recording/janitor/janitorLease.safety.test.ts`:
- Around line 202-209: Remove the unnecessary fs.utimes call targeting tempDir
in the corrupt lease setup, leaving the leasePath mtime update as the sole
age-setting operation before checkLeaseStaleness is exercised.
- Around line 435-494: Ensure the subprocess spawned in the lease test is
terminated whenever the test fails or either wait rejects, using the cleanup
pattern from runBunScript in janitorLease.test.ts. Wrap the spawn-and-wait flow
with guaranteed child cleanup, while preserving normal release handling and
preventing afterEach from removing tempDir while the child remains active.

In `@packages/core/src/recording/janitor/janitorLease.ts`:
- Around line 136-148: Reorder the error cleanup in the file-writing flow so the
handle opened by fsp.open is closed before safeUnlink(tempPath) runs. Update the
try/catch/finally logic around the visible fd variable while preserving EEXIST’s
false return and propagation of other errors.
- Around line 85-86: Move heartbeat state out of the static class fields and
bind each timer and in-flight heartbeat to the lease handle created by
makeHandle. Update startHeartbeat and stopHeartbeat to operate on that handle’s
state so acquiring a second lease cannot stop the first lease’s heartbeat, while
preserving cleanup for each individual lease.
- Around line 401-414: Update acquireTransitionClaim to return an explicit state
distinguishing an actually created claim from the ENOENT no-lease case, while
preserving contention handling through tryReclaimClaim. In the caller of
acquireTransitionClaim, invoke releaseTransitionClaim only when the state
confirms this process created the claim, and skip release for the no-lease
result.

In `@packages/core/src/recording/janitor/reclamationEngine.ts`:
- Around line 83-105: Exclude setUnlinkFaultForTest from the janitor package
barrel in packages/core/src/recording/janitor/index.ts so it is not exposed
through the public package API. Keep the function available via direct module
imports for tests, while preserving the existing platformUnlink behavior.

In `@packages/core/src/recording/janitor/sessionGrouping.ts`:
- Around line 86-95: Update compareGroupsOldestFirst to replace localeCompare in
the normalized representative-path tie-break with a locale-independent byte-wise
comparison, while preserving the mtime ordering and deterministic handling of
equal paths.

In `@packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts`:
- Around line 587-618: Update the inline budget comment in the equal-mtime test
to describe the archives by their logical byte size, as measured by stat().size,
rather than allocated filesystem blocks. Keep the budget explanation consistent
with the actual size-based behavior of sessionScanner and
evictArchivesForBudget.
- Around line 38-118: Extract makeTempDir, validHash64, makeConfig,
createSession, and fileExists into a shared test-support module, then import and
reuse them from both sessionJanitor.reclamation.test.ts and
sessionJanitor.safety.test.ts. Preserve the safety test’s try/finally
flush-and-dispose behavior in the shared createSession implementation, and leave
reclamation-specific makeArchive and listArchives local.

In `@packages/core/src/recording/janitor/sessionJanitor.safety.test.ts`:
- Around line 150-182: Gate both symlink safety tests, including the test around
runSessionCleanup and the additional test at the referenced section, with
it.skipIf(process.platform === 'win32') so they are skipped on Windows before
execution. Remove any equivalent early-return approach if present and leave the
test assertions unchanged.

In `@packages/core/src/recording/janitor/sessionJanitor.test.ts`:
- Around line 43-50: Import the Node crypto module explicitly in
sessionJanitor.test.ts and update validHash64 and makeConfig to use that
imported crypto reference instead of the global. Match the existing node:crypto
import style used by neighboring files.

In `@packages/core/src/recording/janitor/sessionJanitor.ts`:
- Around line 341-353: Update buildFinalResult to avoid unconditionally calling
scanGlobalSessions for the final state. Derive bytesAfter from bytesBefore minus
the bytes successfully reclaimed during the sweep, and only perform the final
rescan when the existing or added option explicitly requests exact post-state
bytes; preserve rescan error accounting when that option is enabled.

In `@packages/core/src/recording/SessionLockManager.internals.ts`:
- Around line 199-222: Update tryCreateLock and its acquire caller so a
persistent temp-file write failure caused by ENOENT is propagated as an I/O
error rather than returned as lock contention, while preserving false for
genuine EEXIST contention. Ensure acquire only invokes tryStaleTakeover when the
lock actually exists, and retain cleanup of the temporary path on failure.

In `@packages/core/src/recording/SessionLockManager.lazy.test.ts`:
- Around line 46-89: Extract the shared spawn, timeout, cleanup, and settlement
logic from runBunScript into a common test-utility module, then import and reuse
that helper in both SessionLockManager.lazy.test.ts and
SessionLockManager.safety.test.ts. Preserve the existing timeout, stdout
handling, error reporting, and Promise settlement behavior.

In `@packages/core/src/recording/SessionLockManager.property.test.ts`:
- Around line 827-873: Replace the three deterministic itProp cases around
checkStaleWithPidReuse with it, or add an age arbitrary that exercises both
sides of the 48-hour threshold. Prefer generated ages if retaining property
tests, and assert stale only when the generated age exceeds the bound while
recent locks remain non-stale.

In `@packages/core/src/recording/SessionLockManager.safety.test.ts`:
- Around line 381-430: Update runBunScript to track whether the timeout callback
terminated the child by setting a timedOut flag before sending SIGTERM, then
have the close-handler rejection message explicitly report the timeout when that
flag is set while preserving the existing exit-code and stderr details
otherwise.

In `@packages/core/src/recording/SessionLockManager.ts`:
- Around line 56-67: Centralize SAFE_ID_MAX_LENGTH, SAFE_SESSION_ID_RE,
isValidSafeSessionId, and isDirectChildPath in a dependency-free shared module,
then update SessionLockManager and janitor/sessionSafety.ts to reuse those
helpers instead of duplicating them. Preserve the existing validation behavior
and avoid introducing filesystem dependencies into the shared module.

In `@schemas/settings.schema.json`:
- Around line 254-272: Update the source schema definition for the retention
settings, not the generated JSON, to add positive numeric bounds for
maxTotalSizeMB and maxCount. Use the existing schema constraints conventions so
editors reject zero and negative values while preserving their current defaults
and descriptions.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c8d4a532-9e83-4fc1-b2f8-96f32b66bd70

📥 Commits

Reviewing files that changed from the base of the PR and between b4c6430 and 02ebf2e.

⛔ Files ignored due to path filters (1)
  • project-plans/issue3164/plan.md is excluded by !project-plans/**
📒 Files selected for processing (45)
  • docs/cli/configuration.md
  • packages/cli/src/config/settings-schema/schema-core.ts
  • packages/cli/src/config/settings.ts
  • packages/cli/src/utils/sessionCleanup-test-helpers.ts
  • packages/cli/src/utils/sessionCleanup.boundary.test.ts
  • packages/cli/src/utils/sessionCleanup.config.test.ts
  • packages/cli/src/utils/sessionCleanup.integration.test.ts
  • packages/cli/src/utils/sessionCleanup.test.ts
  • packages/cli/src/utils/sessionCleanup.ts
  • packages/cli/src/utils/sessionUtils.ts
  • packages/core/package.json
  • packages/core/src/recording/ReplayEngine.ts
  • packages/core/src/recording/SessionDiscovery.ts
  • packages/core/src/recording/SessionLockManager.internals.ts
  • packages/core/src/recording/SessionLockManager.lazy.test.ts
  • packages/core/src/recording/SessionLockManager.property.test.ts
  • packages/core/src/recording/SessionLockManager.safety.test.ts
  • packages/core/src/recording/SessionLockManager.test.ts
  • packages/core/src/recording/SessionLockManager.ts
  • packages/core/src/recording/boundedHeaderReader.test.ts
  • packages/core/src/recording/boundedHeaderReader.ts
  • packages/core/src/recording/janitor/archiveCompressor.safety.test.ts
  • packages/core/src/recording/janitor/archiveCompressor.test.ts
  • packages/core/src/recording/janitor/archiveCompressor.ts
  • packages/core/src/recording/janitor/cleanupTypes.ts
  • packages/core/src/recording/janitor/index.ts
  • packages/core/src/recording/janitor/janitorLease.safety.test.ts
  • packages/core/src/recording/janitor/janitorLease.test.ts
  • packages/core/src/recording/janitor/janitorLease.ts
  • packages/core/src/recording/janitor/reclamationEngine.ts
  • packages/core/src/recording/janitor/retentionPolicy.test.ts
  • packages/core/src/recording/janitor/retentionPolicy.ts
  • packages/core/src/recording/janitor/sessionGrouping.ts
  • packages/core/src/recording/janitor/sessionHeaderReader.test.ts
  • packages/core/src/recording/janitor/sessionHeaderReader.ts
  • packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.safety.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.ts
  • packages/core/src/recording/janitor/sessionSafety.test.ts
  • packages/core/src/recording/janitor/sessionSafety.ts
  • packages/core/src/recording/janitor/sessionScanner.test.ts
  • packages/core/src/recording/janitor/sessionScanner.ts
  • packages/test-utils/src/interactive-run.test.ts
  • schemas/settings.schema.json
💤 Files with no reviewable changes (5)
  • packages/cli/src/utils/sessionCleanup.config.test.ts
  • packages/cli/src/utils/sessionCleanup-test-helpers.ts
  • packages/cli/src/utils/sessionCleanup.test.ts
  • packages/cli/src/utils/sessionCleanup.integration.test.ts
  • packages/cli/src/utils/sessionUtils.ts

Comment thread packages/core/src/recording/boundedHeaderReader.ts Outdated
Comment thread packages/core/src/recording/janitor/archiveCompressor.test.ts Outdated
Comment thread packages/core/src/recording/janitor/archiveCompressor.ts
Comment thread packages/core/src/recording/janitor/janitorLease.test.ts Outdated
Comment thread packages/core/src/recording/janitor/janitorLease.test.ts Outdated
Comment thread packages/core/src/recording/janitor/sessionScanner.ts
Comment thread packages/core/src/recording/SessionLockManager.internals.ts
Comment thread packages/core/src/recording/SessionLockManager.safety.test.ts
Comment thread packages/core/src/recording/SessionLockManager.test.ts
Comment thread packages/core/src/recording/SessionLockManager.ts
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

OpenCodeReview — automatic reviews suspended

Automatic OCR reviews are suspended for this PR after 2 of 2 automatic reviews.

To get more reviews you can:

  • Check the box below to re-enable automatic reviews (resets the counter), or

  • Comment /review, /ocr, or /open-code-review to request a single review on demand.

  • Re-enable automatic reviews

@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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/recording/SessionLockManager.safety.test.ts`:
- Around line 471-485: Make the readiness loop fail if any ready file is still
absent after its deadline: track whether fs.access succeeds for each readyFile,
and throw a timeout error immediately when it does not. Ensure goFile is written
only after every readyFile has been confirmed present.

In `@packages/core/src/recording/SessionLockManager.test.ts`:
- Around line 385-388: Strengthen the error assertion in the test around the
SessionLockManager failure path by verifying the propagated filesystem error has
code ENOTDIR, while retaining the existing SessionLockedError exclusion. Use the
captured error variable and assert its error code directly.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faf3685d-22ab-4c9a-9058-dc96dd3db605

📥 Commits

Reviewing files that changed from the base of the PR and between 02ebf2e and 41a130f.

📒 Files selected for processing (23)
  • packages/cli/src/utils/sessionCleanup.ts
  • packages/core/src/recording/SessionJanitor.ts
  • packages/core/src/recording/SessionLockManager.internals.ts
  • packages/core/src/recording/SessionLockManager.lazy.test.ts
  • packages/core/src/recording/SessionLockManager.safety.test.ts
  • packages/core/src/recording/SessionLockManager.test.ts
  • packages/core/src/recording/SessionLockManager.ts
  • packages/core/src/recording/boundedHeaderReader.test.ts
  • packages/core/src/recording/boundedHeaderReader.ts
  • packages/core/src/recording/index.ts
  • packages/core/src/recording/janitor/archiveCompressor.safety.test.ts
  • packages/core/src/recording/janitor/archiveCompressor.test.ts
  • packages/core/src/recording/janitor/archiveCompressor.ts
  • packages/core/src/recording/janitor/cleanupTypes.ts
  • packages/core/src/recording/janitor/index.ts
  • packages/core/src/recording/janitor/janitorLease.test.ts
  • packages/core/src/recording/janitor/reclamationEngine.ts
  • packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.safety.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.ts
  • packages/core/src/recording/janitor/sessionSafety.ts
  • packages/core/src/recording/janitor/sessionScanner.test.ts
  • packages/core/src/recording/janitor/sessionScanner.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/core/src/recording/janitor/index.ts
  • packages/core/src/recording/janitor/janitorLease.test.ts
  • packages/core/src/recording/boundedHeaderReader.ts
  • packages/core/src/recording/janitor/sessionSafety.ts
  • packages/core/src/recording/janitor/archiveCompressor.ts
  • packages/cli/src/utils/sessionCleanup.ts
  • packages/core/src/recording/janitor/sessionScanner.ts
  • packages/core/src/recording/SessionLockManager.internals.ts
  • packages/core/src/recording/SessionLockManager.ts
  • packages/core/src/recording/janitor/reclamationEngine.ts

Comment thread packages/core/src/recording/SessionLockManager.safety.test.ts
Comment thread packages/core/src/recording/SessionLockManager.test.ts
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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.

Actionable comments posted: 6

🧹 Nitpick comments (7)
packages/core/src/recording/SessionLockManager.safety.test.ts (1)

391-440: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The timeout path does not report a timeout.

At Line 404, the timer sends SIGTERM and does not settle the promise. The close handler then rejects with the exit code, so a hung child produces a message without the timeout cause. Set a flag in the timer and include it in the rejection message to make failures diagnosable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/SessionLockManager.safety.test.ts` around lines
391 - 440, Update runBunScript so the timeout callback records that the child
exceeded the 20-second limit before sending SIGTERM. In the close handler, use
that flag to reject with a timeout-specific message while preserving the
existing exit-code and stderr details for non-timeout failures.
packages/core/src/recording/janitor/janitorLease.ts (1)

84-120: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Process-global heartbeat state assumes one lease per process.

heartbeatTimer and inFlightHeartbeat are static. If two lease acquisitions overlap in one process, startHeartbeat clears the first timer, and the first lease then stops heartbeating and can be classified as stale after STALE_LEASE_AGE_MS. releaseLease also calls stopHeartbeat, which stops a newer lease's timer. The current consumer in packages/core/src/recording/janitor/sessionJanitor.ts acquires one lease per sweep, so this is not reachable today. Consider binding the timer state to the handle to keep the invariant local.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/janitorLease.ts` around lines 84 - 120,
The heartbeat state in JanitorLease is process-global, so overlapping leases can
stop one another’s heartbeats. Move heartbeat timer and in-flight heartbeat
tracking from the static JanitorLease fields into each JanitorLeaseHandle, and
update startHeartbeat, stopHeartbeat, and releaseLease to operate only on the
owning handle’s state.
packages/cli/src/utils/sessionCleanup.boundary.test.ts (1)

43-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Import randomUUID from node:crypto.

Replace both crypto.randomUUID() calls with randomUUID() to avoid relying on the global Web Crypto object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/utils/sessionCleanup.boundary.test.ts` around lines 43 - 45,
Import randomUUID from node:crypto in the test module and update validHash64 to
call randomUUID() directly, replacing the existing crypto.randomUUID() usage
while preserving the hash-generation logic.
packages/core/src/recording/janitor/sessionSafety.test.ts (1)

162-190: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Skip both symlink tests on Windows.

The nightly windows-latest job runs npm run test, and unprivileged Windows runners can reject fs.symlink. Use it.skipIf(process.platform === 'win32'), matching existing symlink tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionSafety.test.ts` around lines 162 -
190, Update the symlink-specific tests for isRegularNonSymlinkFile and
isRegularNonSymlinkDir to use it.skipIf(process.platform === 'win32'), while
leaving the regular-file, directory, and non-existent-path tests unchanged.

Source: Learnings

packages/core/src/recording/janitor/sessionJanitor.test.ts (1)

92-106: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

makeArchive writes uncompressed bytes under a .gz name.

The sibling helper in packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts gzips the content. Here the file content is plain text. The current assertions only measure sizes, so the tests pass. If a future change validates gzip magic bytes during scanning or eviction, these fixtures become invalid. Consider gzipping the content for realism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.test.ts` around lines 92 -
106, Update the makeArchive test helper to gzip the provided content before
writing it to the .gz archive path, matching the behavior of the sibling
reclamation test helper while preserving the existing age and return-path
handling.
packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts (1)

131-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a fault-seam reset to this suite's afterEach.

The Item 1 suite installs the process-global unlink fault seam at Line 189. The try/finally block clears it for that test, but a failure before the try block leaves the seam installed for later suites. The Item 4 suite already resets the seam in afterEach. Apply the same safety net here.

♻️ Proposed fix
   afterEach(async () => {
+    setUnlinkFaultForTest(null);
     await fs.rm(tempDir, { recursive: true, force: true });
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts`
around lines 131 - 133, Update this suite’s afterEach cleanup to reset the
process-global unlink fault seam, matching the existing reset used by the Item 4
suite. Ensure the reset runs alongside the tempDir removal so failures before
Item 1’s try/finally cannot leak the seam into later suites.
packages/core/src/recording/janitor/sessionHeaderReader.test.ts (1)

37-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

projectHash is 32 characters, not 64.

crypto.randomUUID().replace(/-/g, '') produces 32 characters, so .slice(0, 64) leaves 32. Sibling test files use .repeat(2) before slicing to build a 64-hex value. If any consumer validates a 64-hex project hash, this helper does not represent real recorder output. Align the helper with the sibling files.

♻️ Proposed fix
-    projectHash: crypto.randomUUID().replace(/-/g, '').slice(0, 64),
+    projectHash: crypto.randomUUID().replace(/-/g, '').repeat(2).slice(0, 64),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/recording/janitor/sessionHeaderReader.test.ts` around lines
37 - 46, Update the projectHash construction in makeConfig to repeat the
hyphen-free UUID before slicing, producing a 64-character hexadecimal value
consistent with sibling test helpers and recorder output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/recording/janitor/janitorLease.safety.test.ts`:
- Around line 435-494: Update the subprocess lifecycle in the test around child,
childStdout, and both wait promises: continuously consume child.stderr, and wrap
the acquisition/assertion/release flow in a finally block that kills the child
if it is still running. Preserve the existing success-path release and lease
assertions while ensuring timeout or assertion failures cannot leave the
subprocess alive.

In `@packages/core/src/recording/janitor/janitorLease.test.ts`:
- Around line 254-284: Harden the concurrency test around the child-process
script and its lease-holding delay so the first winner keeps the lease until
both subprocesses have had time to call JanitorLease.tryAcquire. Increase the
contention window or coordinate release with an external signal, while
preserving the expectation that exactly one result is WON.

In `@packages/core/src/recording/janitor/janitorLease.ts`:
- Around line 401-414: Update acquireTransitionClaim and tryReclaimClaim to
return whether a real claim was created, rather than treating ENOENT as an owned
claim; preserve the no-lease outcome without claiming ownership. Propagate this
ownership result through tryStaleTakeover, updateHeartbeat, and releaseLease,
and call releaseTransitionClaim only when the caller actually holds the claim.
- Around line 130-161: Update tryCreateLease so the file descriptor is closed
before safeUnlink(tempPath) runs when creating or writing the temporary lease
file fails. Preserve the existing EEXIST retry behavior and propagation of other
I/O errors, ensuring cleanup occurs after closure on all platforms.

In `@packages/core/src/recording/janitor/sessionScanner.test.ts`:
- Around line 281-353: Gate every test that directly creates or inspects
symlinks with it.skipIf(process.platform === 'win32') instead of returning
early: the four symlink tests in
packages/core/src/recording/janitor/sessionScanner.test.ts (lines 281-353), the
three symlink tests and lstat symlink test in
packages/core/src/recording/janitor/archiveCompressor.safety.test.ts (lines
61-115 and 182), and the symlinked-archive and symlink-replacement tests in
packages/core/src/recording/janitor/sessionJanitor.safety.test.ts (lines 150-182
and 196). Use the existing platform-gate pattern near sessionScanner.test.ts
line 379.

In `@packages/core/src/recording/SessionLockManager.test.ts`:
- Line 386: Update the error assertion in the Windows path test to accept either
ENOTDIR or ENOENT, while preserving validation that the error code matches one
of these expected values.

---

Nitpick comments:
In `@packages/cli/src/utils/sessionCleanup.boundary.test.ts`:
- Around line 43-45: Import randomUUID from node:crypto in the test module and
update validHash64 to call randomUUID() directly, replacing the existing
crypto.randomUUID() usage while preserving the hash-generation logic.

In `@packages/core/src/recording/janitor/janitorLease.ts`:
- Around line 84-120: The heartbeat state in JanitorLease is process-global, so
overlapping leases can stop one another’s heartbeats. Move heartbeat timer and
in-flight heartbeat tracking from the static JanitorLease fields into each
JanitorLeaseHandle, and update startHeartbeat, stopHeartbeat, and releaseLease
to operate only on the owning handle’s state.

In `@packages/core/src/recording/janitor/sessionHeaderReader.test.ts`:
- Around line 37-46: Update the projectHash construction in makeConfig to repeat
the hyphen-free UUID before slicing, producing a 64-character hexadecimal value
consistent with sibling test helpers and recorder output.

In `@packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts`:
- Around line 131-133: Update this suite’s afterEach cleanup to reset the
process-global unlink fault seam, matching the existing reset used by the Item 4
suite. Ensure the reset runs alongside the tempDir removal so failures before
Item 1’s try/finally cannot leak the seam into later suites.

In `@packages/core/src/recording/janitor/sessionJanitor.test.ts`:
- Around line 92-106: Update the makeArchive test helper to gzip the provided
content before writing it to the .gz archive path, matching the behavior of the
sibling reclamation test helper while preserving the existing age and
return-path handling.

In `@packages/core/src/recording/janitor/sessionSafety.test.ts`:
- Around line 162-190: Update the symlink-specific tests for
isRegularNonSymlinkFile and isRegularNonSymlinkDir to use
it.skipIf(process.platform === 'win32'), while leaving the regular-file,
directory, and non-existent-path tests unchanged.

In `@packages/core/src/recording/SessionLockManager.safety.test.ts`:
- Around line 391-440: Update runBunScript so the timeout callback records that
the child exceeded the 20-second limit before sending SIGTERM. In the close
handler, use that flag to reject with a timeout-specific message while
preserving the existing exit-code and stderr details for non-timeout failures.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b547cf4b-23cb-4313-90fc-bf393d8e3417

📥 Commits

Reviewing files that changed from the base of the PR and between b4c6430 and 3c57475.

⛔ Files ignored due to path filters (1)
  • project-plans/issue3164/plan.md is excluded by !project-plans/**
📒 Files selected for processing (48)
  • docs/cli/configuration.md
  • packages/cli/src/config/settings-schema/schema-core.ts
  • packages/cli/src/config/settings.ts
  • packages/cli/src/ui/components/AuthDialog.test.tsx
  • packages/cli/src/utils/sessionCleanup-test-helpers.ts
  • packages/cli/src/utils/sessionCleanup.boundary.test.ts
  • packages/cli/src/utils/sessionCleanup.config.test.ts
  • packages/cli/src/utils/sessionCleanup.integration.test.ts
  • packages/cli/src/utils/sessionCleanup.test.ts
  • packages/cli/src/utils/sessionCleanup.ts
  • packages/cli/src/utils/sessionUtils.ts
  • packages/core/package.json
  • packages/core/src/recording/ReplayEngine.ts
  • packages/core/src/recording/SessionDiscovery.ts
  • packages/core/src/recording/SessionJanitor.ts
  • packages/core/src/recording/SessionLockManager.internals.ts
  • packages/core/src/recording/SessionLockManager.lazy.test.ts
  • packages/core/src/recording/SessionLockManager.property.test.ts
  • packages/core/src/recording/SessionLockManager.safety.test.ts
  • packages/core/src/recording/SessionLockManager.test.ts
  • packages/core/src/recording/SessionLockManager.ts
  • packages/core/src/recording/boundedHeaderReader.test.ts
  • packages/core/src/recording/boundedHeaderReader.ts
  • packages/core/src/recording/index.ts
  • packages/core/src/recording/janitor/archiveCompressor.safety.test.ts
  • packages/core/src/recording/janitor/archiveCompressor.test.ts
  • packages/core/src/recording/janitor/archiveCompressor.ts
  • packages/core/src/recording/janitor/cleanupTypes.ts
  • packages/core/src/recording/janitor/index.ts
  • packages/core/src/recording/janitor/janitorLease.safety.test.ts
  • packages/core/src/recording/janitor/janitorLease.test.ts
  • packages/core/src/recording/janitor/janitorLease.ts
  • packages/core/src/recording/janitor/reclamationEngine.ts
  • packages/core/src/recording/janitor/retentionPolicy.test.ts
  • packages/core/src/recording/janitor/retentionPolicy.ts
  • packages/core/src/recording/janitor/sessionGrouping.ts
  • packages/core/src/recording/janitor/sessionHeaderReader.test.ts
  • packages/core/src/recording/janitor/sessionHeaderReader.ts
  • packages/core/src/recording/janitor/sessionJanitor.reclamation.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.safety.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.test.ts
  • packages/core/src/recording/janitor/sessionJanitor.ts
  • packages/core/src/recording/janitor/sessionSafety.test.ts
  • packages/core/src/recording/janitor/sessionSafety.ts
  • packages/core/src/recording/janitor/sessionScanner.test.ts
  • packages/core/src/recording/janitor/sessionScanner.ts
  • packages/test-utils/src/interactive-run.test.ts
  • schemas/settings.schema.json
💤 Files with no reviewable changes (5)
  • packages/cli/src/utils/sessionCleanup.config.test.ts
  • packages/cli/src/utils/sessionCleanup.integration.test.ts
  • packages/cli/src/utils/sessionCleanup.test.ts
  • packages/cli/src/utils/sessionCleanup-test-helpers.ts
  • packages/cli/src/utils/sessionUtils.ts
🚧 Files skipped from review as they are similar to previous changes (21)
  • packages/core/package.json
  • docs/cli/configuration.md
  • packages/core/src/recording/janitor/index.ts
  • packages/core/src/recording/index.ts
  • packages/cli/src/config/settings-schema/schema-core.ts
  • packages/core/src/recording/ReplayEngine.ts
  • packages/core/src/recording/SessionJanitor.ts
  • packages/core/src/recording/janitor/sessionHeaderReader.ts
  • packages/core/src/recording/janitor/cleanupTypes.ts
  • packages/core/src/recording/boundedHeaderReader.ts
  • packages/core/src/recording/SessionDiscovery.ts
  • packages/core/src/recording/janitor/retentionPolicy.ts
  • packages/core/src/recording/SessionLockManager.ts
  • packages/test-utils/src/interactive-run.test.ts
  • packages/core/src/recording/janitor/sessionGrouping.ts
  • packages/core/src/recording/janitor/reclamationEngine.ts
  • packages/cli/src/utils/sessionCleanup.ts
  • packages/cli/src/config/settings.ts
  • packages/core/src/recording/janitor/retentionPolicy.test.ts
  • schemas/settings.schema.json
  • packages/core/src/recording/janitor/sessionJanitor.ts

Comment thread packages/core/src/recording/janitor/janitorLease.safety.test.ts Outdated
Comment thread packages/core/src/recording/janitor/janitorLease.test.ts
Comment thread packages/core/src/recording/janitor/janitorLease.ts
Comment thread packages/core/src/recording/janitor/janitorLease.ts
Comment thread packages/core/src/recording/janitor/sessionScanner.test.ts Outdated
Comment thread packages/core/src/recording/SessionLockManager.test.ts Outdated
@acoliver
acoliver merged commit 62f5fcd into main Aug 10, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer:e2e:ok Trusted contributor; maintainer-approved E2E run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session cleanup is completely non-functional: 3.8 GB accumulates, 10k orphaned project dirs, 417 stale locks (five independent defects)

1 participant