Repository navigation
fix: refresh cached project sessions from disk and guard fix writes against stale content - #37
Merged
Merged
Conversation
…gainst stale content
ProjectSession.RefreshFromDiskAsync incrementally syncs the in-memory
workspace with on-disk .al files before every get_fixes / apply_fix /
apply_fix_all call. Only changed/added/removed files are touched;
untouched documents keep their DocumentId and compilation state.
Mutations go through Workspace.OnDocument* (matching almcp's
ProjectWatcher pattern), never TryApplyChanges on a forked solution.
GuardedFileWriter.WriteIfUnchangedAsync verifies the file still equals
the text the fix was computed from before writing. apply_fix returns
{ error: "StaleFile" } on conflict; apply_fix_all writes non-conflicting
files and lists the rest in "conflicts".
Fixes #36
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
F0: Always commit updated file stamps after refresh loops, even when no
content changed. Prevents re-reading touched-but-unchanged files on
every subsequent call. Rebuild the DocumentId map only on add/remove.
Added TrackedDocuments property and convergence test.
F1: Widen per-file catch in RefreshFromDiskAsync to also handle
UnauthorizedAccessException (locked/permission-denied files skip
with a stderr warning instead of aborting the whole call).
F2: GuardedFileWriter now catches DirectoryNotFoundException alongside
FileNotFoundException. ApplyFixAllTool write loop has a per-file
guard so an IOException/UnauthorizedAccessException on one file
becomes a conflict entry instead of aborting accounting for already-
written files. Added MissingParentDirectory test.
F3: Removed dead try/catch (IOException) around in-memory workspace
OnDocument* calls that can never throw IOException.
F4: README and AGENTS.md now name the three tools that refresh from disk
(get_fixes, apply_fix, apply_fix_all) instead of overstating.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
G1: Extract TryStat/TryReadAsync helpers in ProjectSession so that FileStamp.Of (which actually stats the file) and File.ReadAllTextAsync are both guarded against IOException/UnauthorizedAccessException. The previous code only wrapped `new FileInfo(path)` which never touches disk. All four duplicated try/catch sites now use the shared helpers. Tests: LockedFile_RefreshCompletes_FileSkipped (Windows-only exclusive lock), TryStat/TryReadAsync on nonexistent paths returning null. G2: FixAllFileChange now carries the original Diagnostics per changed file. CodeFixRunner populates them via a shared ToLocation helper (extracted from FindRemainingDiagnosticsAsync). ApplyFixAllTool.MergeUnfixed reconciles: when a file write is skipped as a conflict, its diagnostics are appended to unfixedDiagnostics so callers see them as unresolved. Tool description updated. Tests: Changes_CarryDiagnosticLocations (CodeFixRunner level), MergeUnfixed_NoConflicts, _WithConflict, _CaseInsensitivePathMatch (all unit-level). G3: ProjectSession.Dispose() now sets a _disposed flag, acquires the gate with a 5s timeout before disposing, and RefreshFromDiskAsync checks _disposed after acquiring the gate (throws ObjectDisposedException). The finally block swallows ObjectDisposedException from Release() to avoid crashing on shutdown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
H1: RefreshFromDiskAsync — move _documents commit to finally block so a cancellation or exception mid-refresh cannot desync _documents from the workspace. newDocs and counters declared before try; newDocs is not null guard protects the _disposed throw path. New test: CancelledToken_ThrowsAndLeavesStateConsistent. H2: Dispose — idempotent early return when _disposed; gate is only disposed after a successful Wait (leaked on timeout to avoid ObjectDisposedException on pending waiters). New test: Dispose_Idempotent. H3: apply_fix_all description — added "(positions as analysed, so they may have shifted if the file was edited)" after unfixedDiagnostics. Skipped: deterministic mid-pass cancellation test (no clean hook to cancel between the remove and add loops without introducing test-only seams into production code). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- MergeUnfixed dedupes the merged list so a conflict file's diagnostic that was already reported as unfixed is listed once; regression test added. - Rename the pre-cancelled-token refresh test to say what it covers (the gate rejects an already-cancelled token before any mutation) and note why mid-pass cancellation has no deterministic test hook. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Oct 3, 2026
Arthurvdv
added a commit
that referenced
this pull request
Oct 3, 2026
…pply_fix and apply_fix_all (#48) apply_fix and apply_fix_all were the only tools that wrote .al files, and the write was a truncating in-place File.WriteAllTextAsync with the default encoding: a UTF-8-with-BOM file lost its BOM (whole-file git diff), a UTF-16 file was converted to UTF-8, a crash mid-write could leave a truncated file, and a failed write in apply_fix_all left the earlier files of the batch modified. GuardedFileWriter is now a DI singleton and the only code that writes .al files: - Atomic: the new bytes go to a sibling <file>.<guid>.alcops.tmp in the same directory, which is then moved over the target with File.Move(overwrite: true). The temp is deleted in finally; a stray one left by a hard kill is swept on project load and, when older than 30 s, on refresh. Never File.Replace and never a backup file: almcp's ProjectWatcher treats a move-over as an in-place update that keeps the DocumentId, while replace-with-backup removes and re-adds the document. - Encoding-preserving: the file is read as bytes for the existing stale check, the BOM is sniffed and the text decoded strictly in that encoding (UTF-8 with/without BOM, UTF-16 LE/BE, UTF-32 LE/BE), and the same encoding and BOM are written back. Line endings round-trip. A file with bytes that are invalid in its detected encoding (e.g. Windows-1252) is refused as UnsupportedEncoding instead of being rewritten with U+FFFD. The encoding is detected at write time rather than stored on TrackedDocument, so an editor changing the BOM between load and write is honoured and nothing is plumbed through the fix pipeline. - Batch rollback: apply_fix_all stages every file first (changed, deleted, unreadable or invalidly encoded files drop out as conflicts and the rest proceed, as in #37), then commits one by one. Any exception during a commit restores every file already written in that call from the original bytes held in memory and reports the whole staged set in conflicts with applied: false. A file edited after this call wrote it, or whose rollback fails, is named in message and stays in filesChanged. - conflicts entries gain a kind (StaleFile, UnsupportedEncoding, ReadFailed, WriteFailed, RolledBack, NotWritten); apply_fix returns the kind as error. Additive; #41 owns the vocabulary. Tests: 257 -> 293. BOM and invalid-byte fixtures are created at test time, nothing with a BOM is committed; failure injection goes through an internal move seam so every new test runs on the ubuntu CI matrix. Fixes #40 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Summary
ProjectSession.RefreshFromDiskAsyncincrementally syncs the in-memory workspace with on-disk.alfiles before everyget_fixes/apply_fix/apply_fix_allcall. Only changed/added/removed files are touched (length+mtime gate, then ordinal content compare); untouched documents keep theirDocumentIdand compilation state. Mutations go throughWorkspace.OnDocument*like almcp'sProjectWatcher.GuardedFileWriter.WriteIfUnchangedAsyncverifies the file still equals the text the fix was computed from before writing.apply_fixreturns{ error: "StaleFile" }on conflict;apply_fix_allwrites non-conflicting files, lists the rest inconflictsand keeps their diagnostics inunfixedDiagnostics.ReloadProjectAsync(full teardown + reload) is removed; the incremental refresh replaces it.Warning:) instead of failing the whole call; a failing write inapply_fix_allbecomes aconflictsentry instead of aborting the loop; refresh bookkeeping is committed in afinally, so a cancelled refresh cannot leave the tracked-document map out of sync with the workspace;ProjectSession.Disposeis idempotent and waits (bounded) for an in-flight refresh.Root cause
ProjectSessionManagercached aProjectSessionper project path for the life of the server.ProjectLoader.LoadProjectAsynceagerly read every.alfile into in-memoryTextLoaders at load time, and nothing re-read them except theReloadProjectAsyncthatapply_fix/apply_fix_allcalled after their own write.External edits made between calls were invisible to
get_fixes,apply_fix, andapply_fix_all.apply_fix_allwrote whole-file content computed from stale text and silently reverted external edits.The issue mentions
al_downloadsymbols force:trueas a workaround. That only recreates almcp's own workspace (CompilationService.ReloadWorkspace), never ours — the in-processProjectSessionwas unaffected by almcp's reload, so the staleness persisted.AA0248
The
this.MaxStrLenfalse positives from issue #36 come from Microsoft's CodeCopRule248AddThis, which excludes built-ins viaTargetMethod.IsStatic(every listed built-in isisStatic: truein the compiler symbol tables); the syntacticAddThisCodeActiononly fires where the analyzer reported. Those false positives most plausibly came from unresolved invocations (error symbols, non-static) in a stale or symbol-less in-process compilation — a downstream effect of this same staleness bug. No code change for AA0248 in this PR; the refresh fix should eliminate the root cause.Behaviour changes
apply_fixOn conflict (file changed on disk after the fix was computed):
{ "error": "StaleFile", "message": "<filePath> changed on disk after the fix was computed; nothing was written. Re-run get_fixes and apply_fix.", "filePath": "...", "diagnosticId": "..." }apply_fix_allThe Completed response now always includes
conflicts(empty[]when none):{ "applied": true, "dryRun": false, "diagnosticId": "LC0020", "filesChanged": ["PageA.al"], "conflicts": [ { "filePath": "PageB.al", "message": "PageB.al changed on disk after the fix was computed; not overwritten." } ], "message": "1 file(s) were skipped because they changed on disk while the fix was being computed; their diagnostics are included in unfixedDiagnostics; re-run apply_fix_all to fix them.", "unfixedDiagnostics": [ { "filePath": "PageB.al", "line": 11, "column": 17 } ] }unfixedDiagnostics(deduplicated against the ones the fix-all pass itself could not fix), so callers that checkunfixedDiagnostics.length == 0correctly see them as unresolved. Their positions are the analysis-time positions and may have shifted if the file was edited; the tool description says so.appliedistrueas soon as at least one file was written;conflictsandmessagecarry the partial-success detail.conflictsentry too; the remaining files are still processed.No post-write reload
There is no explicit
ReloadProjectAsyncafter writing. The nextGetOrLoadProjectAsynccall picks the written file up via the sameRefreshFromDiskAsyncthat runs on every cache hit.Tests
NothingChanged_SummaryAllZero_SameSolutionSolutioninstance, sameDocumentIdsEditFile_UpdatedCountIsOne_NewTextVisible// editedto PageB.al:Updated == 1, new text inGetDocument, IDs preservedAddFile_AddedCountIsOne_DocumentAccessibleAdded == 1, document accessible, doc count increasedDeleteFile_RemovedCountIsOne_CompilationSucceedsRemoved == 1, document null, compilation succeedsTouchWithoutContentChange_SummaryZero_SameSolutionSolution(content-compare fallback)TouchWithoutContentChange_StampConvergesFileInAlPackages_NotAdded.alpackages/X.al:Added == 0ViaSessionManager_EditVisibleOnSecondCall_SameInstanceGetOrLoadProjectAsynccalls: sameProjectSessioninstance, edit visibleLockedFile_RefreshCompletes_FileSkippedPreCancelledToken_ThrowsBeforeMutating_NextRefreshPicksUpEverythingDispose_IdempotentDisposetwice does not throwTryStat_NonexistentPath_ReturnsNull/TryReadAsync_NonexistentPath_ReturnsNullApplyFixAll_ExternalEditBetweenCalls_PreservesExternalEditApplyFixAll_FileAddedAfterLoad_FixesNewFilediagnosticsFound == 3, PageD infilesChangedApplyFixAll_FileDeletedAfterLoad_FixesRemainingFilesdiagnosticsFound == 1, only PageA infilesChangedApplyFixAll_ProjectScope_FixesAllOccurrencesAcrossFilesAssert.Empty(conflicts)ApplyFixAll_Changes_CarryDiagnosticLocationsFixAllFileChangehas non-emptyDiagnosticswith correct 1-based line numbersMergeUnfixed_*(4 cases)ApplyFix_ExternalEditBetweenCalls_PreservesEditAndAppliesFix// edited, apply fix:applied == true, file contains// edited, exactly one ApplicationAreaGuardedFileWriterTests(4 cases)EnumerateAlFiles_ExcludesAlPackages_ReturnsFullPaths.alpackages/*.alexcluded, full paths returnedRegression test vs old code: The new tests cannot compile against the old code (
CodeFixResultgainedOriginalContent, the JSON response gainedconflicts), so the stash-and-run dance is impractical. From the diff: the oldApplyFixAllToolcalledFile.WriteAllTextAsync(change.FilePath, change.ModifiedContent)withModifiedContentcomputed from the cached (stale) workspace text, so test 13'sRenamedFieldrename would have been overwritten by the staleOtherFieldcontent. The new code callsRefreshFromDiskAsyncbefore computing the fix and usesGuardedFileWriterfor the write, making the rename survive.Verification
dotnet build --configuration Release: 0 warnings, 0 errors.dotnet test --configuration Release: 257 passed, 0 skipped (Windows, DevTools 18.0.41).LockedFile_RefreshCompletes_FileSkipped) returns early off Windows.fix: address review round Ncommits. Follow-up: ProjectSession.Dispose disposes the workspace while a timed-out refresh may still be running #38 (Disposestill disposes the workspace when its bounded wait for an in-flight refresh times out; shutdown-only).Fixes #36
🤖 Generated with Claude Code