Fix/1149 timeline prune active session - #1185
Open
MayurK-cmd wants to merge 2 commits into
Open
Conversation
MayurK-cmd
requested review from
Avtrkrb,
akramcodez and
will-lamerton
as code owners
September 4, 2026 12:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #1149
Adds a per-process lockfile at
.nanocoder/timeline/<sessionId>/.locksopruneStaleSessionsno longer wipes a session directory that another in-flight process is still writing into.A long-running session mid-tool-call used to be vulnerable: its session directory's
mtimeMslagged behindnow - MAX_TIMELINE_SESSION_AGE_MSwhenever no new entry was being captured, andpruneStaleSessionscouldfs.rmthe entire directory, breaking the active tool call.Pruning is now gated on a liveness probe of the lockfile:
The session directory's mtime is also refreshed on every
ensureDirviafs.utimesso an active session naturally bubbles to the top of the count cap and out of the age-based cutoff.How it works
New module:
source/services/timeline-lock.tsSelf-contained, mirrors
daemon/lockfile.ts:acquireTimelineLock(sessionDir, {pid, startedAt})— creates a temporary lockfile using exclusive creation (O_EXCL), then atomically renames it into place. Returnsfalse(does not throw) on contention so the chat never blocks.releaseTimelineLock(sessionDir)— idempotent unlink;ENOENTis ignored.isTimelineLockLive(sessionDir)— reads the lock, validates thepurposetag, probes the holder's PID withprocess.kill(pid, 0), and reaps stale locks as a side effect.isProcessAlive(pid)is duplicated fromdaemon/lockfile.tsfor now; a follow-up can lift it into a shared util.The lock payload is
{pid, startedAt, purpose: 'session-active'}. The purpose tag allowsisTimelineLockLiveto distinguish a real timeline lock from a random JSON file another tool may have dropped in the session directory.TimelineManagerchangesensureDircall (best-effort: failure is logged, never thrown).ensureDirdoes not fliplockHeldback tofalseby racing its own on-disk lockfile.mtimeon everyensureDirviafs.utimesso an active session bubbles to the top of the count cap and out of the age-based cutoff.async dispose()that releases the lock and clears thelockHeldflag. Idempotent and safe to call on managers that never acquired.pruneStaleSessionschangesProbes the lockfile for every stale entry. If
isTimelineLockLivereports the lock as held by a live process, the entry is skipped. A dead, malformed, or wrong-purpose lock is reaped beforefs.rmruns, so the directory leaves the timeline root in a clean state.Why no lifecycle wiring?
The lock remains useful without an explicit
dispose()call:isTimelineLockLive()detects lockfiles whose recorded PID is no longer alive and reaps them during pruning.dispose()provides prompt cleanup when lifecycle wiring is available, but it is not required for correctness.A follow-up can plumb
dispose()into theApp/AcpSessionteardown so production code releases the lock promptly. This is out of scope for this fix.Type of Change
Changeset
pnpm changeset) describing this change for the changelog.changeset/fix-1149-timeline-prune-lock.mdis apatchchangeset naming@nanocollective/nanocoderand explaining the lockfile and live-skip behaviour.Validated locally with
node scripts/validate-changesets.js(73 changesets checked, all resolve).Docs-only or internal chores need no changeset (or run
pnpm changeset --emptyto note that intentionally).Testing
Automated Tests
pnpm test:allcompletes successfully)Verified locally:
pnpm test:ava source/services/timeline-lock.spec.ts→ 10/10 passedpnpm test:ava source/services/timeline-manager.spec.ts→ 21/21 passed (17 existing + 4 new)pnpm test:types→ cleanpnpm test:lint→ 517 files, 0 fixesNew tests cover:
In
timeline-lock.spec.ts:isProcessAlivefor current process and invalid PIDsIn
timeline-manager.spec.ts:TimelineManager prunes abandoned sessions whose lockfile points to a dead process— proves the lock reaper andfs.rmcooperate.TimelineManager skips pruning a stale-but-live session (issue #1149)— proves the regression this PR fixes.TimelineManager.dispose releases the session lock— proves the lifecycle.TimelineManager.dispose does not throw when the lock was never acquired— proves the idempotency.Success and error scenarios are both covered: the live-skip and dead-reap tests exercise the two paths in
pruneStaleSessions, while the malformed, wrong-purpose, and missing-lock tests cover the stale outcomes forisTimelineLockLive.Manual Testing
This change is filesystem-only and provider-agnostic. The lockfile lives under
.nanocoder/timeline/<sessionId>/.lockand is read/written vianode:fs/promiseswith no LLM or network calls.Manual end-to-end testing against a real provider was not done as part of this PR; the change was validated via the unit/integration tests listed above.
To manually reproduce the original bug and verify the fix, the steps from the issue are: start a long-running session, then in a second shell run a flood of
nanocoderinvocations that each create a new session so the count cap is hit, and observe that the long-running session's directory is no longer removed.Checklist
CONTRIBUTING.md#logging)No documentation update included in this PR — the lockfile is an internal implementation detail of
TimelineManager; the user-visible behaviour (pruning) is unchanged for the normal case. Happy to add a docs page if reviewers want one.No breaking changes: the public surface of
TimelineManageronly grew by the newdispose()method. The constructor signature and all existing method signatures remain unchanged, and the file layout under.nanocoder/timeline/<sessionId>/is unchanged. ThetryAcquireSessionLock,touchSessionDir, and lock-related fields remain private.Logging: the lock acquisition and release paths use the existing
logWarninghelper fromsource/utils/message-queue.tswith structured context (sessionId,error), matching the project's logging conventions. No additionalgetLogger().infocalls were added because the happy path is silent, matching the rest of the timeline subsystem; only the failure path logs.