fix: make update promotion crash-safe and trim speculative machinery - #21
Conversation
Restore interrupted promotions on launch, self-heal stale cache state, surface local update failures in the footer, and remove unused concurrency/path checks. Co-authored-by: Cursor <cursoragent@cursor.com>
Greptile SummaryThis PR changes the automatic update cleanup and launch path. The main changes are:
Confidence Score: 4/5The changed update cleanup and launch paths need fixes before merging. Failed preparation can still show a ready-to-restart update; stale marker cleanup can remove a path that was never validated as the prepared wrapper; malformed markers can prevent crash recovery after src/tui/update-launch.ts and src/tui/update-cleanup.ts
What T-Rex did
|
| Filename | Overview |
|---|---|
| src/tui/update-launch.ts | Extracts launch orchestration, but the ready status is still emitted when preparation fails. |
| src/tui/update-cleanup.ts | Adds crash recovery and stale-state cleanup, but malformed or stale markers can break recovery or widen deletion scope. |
| src/tui/update-manager.ts | Moves cleanup out of the manager and keeps promotion rollback behavior. |
| src/tui/update-paths.ts | Simplifies validation by removing realpath, symlink, hardlink, and parent-cache checks while keeping layout and manifest validation. |
| src/tui/updates.ts | Adds the broken update status and removes speculative in-flight check deduplication. |
| src/tui/board/board.tsx | Displays the new broken update state as an updates-unavailable footer message. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Plugin launch] --> B[cleanupPreparedUpdate]
B -->|cleanup ok| C[checkForUpdate]
B -->|cleanup failed| D[remember cleanup failure]
D --> C
C -->|no status or aborted| E[return]
C -->|ready| F[prepareUpdate]
F --> G[set footer status]
C -->|blocked| G
D -->|after check| H[set broken footer]
G --> I[optional toast]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Plugin launch] --> B[cleanupPreparedUpdate]
B -->|cleanup ok| C[checkForUpdate]
B -->|cleanup failed| D[remember cleanup failure]
D --> C
C -->|no status or aborted| E[return]
C -->|ready| F[prepareUpdate]
F --> G[set footer status]
C -->|blocked| G
D -->|after check| H[set broken footer]
G --> I[optional toast]
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/tui/update-launch.ts:58-62
**Ready Status Without Preparation**
When `prepareUpdate` returns `false` after a host install, validation, marker write, or abort failure, this path still stores and toasts the `ready` status. No promotion callback was registered, so the footer tells the user to restart for an update that cannot be applied.
```suggestion
if (status.kind === "ready" && !(await prepareUpdate({ api, meta, currentVersion, status, fs }))) return
setUpdateStatus(status)
```
### Issue 2 of 3
src/tui/update-cleanup.ts:40
**Stale Marker Controls Deletion**
When a marker parses but does not match the current layout, this branch recursively removes `marker.prepared` before validating that it is Kagan’s expected prepared wrapper. A stale marker from another target or a malformed marker can point outside the intended cache state, causing startup cleanup to delete the wrong directory.
### Issue 3 of 3
src/tui/update-cleanup.ts:18-21
**Corrupt Marker Blocks Recovery**
If the process exits after `current` was renamed to `backup` and the marker is left malformed, this catch deletes the only recovery marker and returns `undefined`. The interrupted-promotion branch never runs, `current` stays missing, and each launch can remain stuck in `updates unavailable` instead of restoring from the validated backup.
Reviews (1): Last reviewed commit: "fix: make update promotion crash-safe an..." | Re-trigger Greptile
| if (status.kind === "ready") { | ||
| await prepareUpdate({ api, meta, currentVersion, status, fs }) | ||
| } | ||
|
|
||
| setUpdateStatus(status) |
There was a problem hiding this comment.
Ready Status Without Preparation
When prepareUpdate returns false after a host install, validation, marker write, or abort failure, this path still stores and toasts the ready status. No promotion callback was registered, so the footer tells the user to restart for an update that cannot be applied.
| if (status.kind === "ready") { | |
| await prepareUpdate({ api, meta, currentVersion, status, fs }) | |
| } | |
| setUpdateStatus(status) | |
| if (status.kind === "ready" && !(await prepareUpdate({ api, meta, currentVersion, status, fs }))) return | |
| setUpdateStatus(status) |
Context Used: AGENTS.md (source)
Artifacts
Repro: focused Bun test harness for ready status after failed preparation
- Contains supporting evidence from the run (text/typescript; charset=utf-8).
Repro: Bun test output showing failed preparation followed by ready status and toast
- Keeps the command output available without making the summary code-heavy.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tui/update-launch.ts
Line: 58-62
Comment:
**Ready Status Without Preparation**
When `prepareUpdate` returns `false` after a host install, validation, marker write, or abort failure, this path still stores and toasts the `ready` status. No promotion callback was registered, so the footer tells the user to restart for an update that cannot be applied.
```suggestion
if (status.kind === "ready" && !(await prepareUpdate({ api, meta, currentVersion, status, fs }))) return
setUpdateStatus(status)
```
**Context Used:** AGENTS.md ([source](https://app.greptile.com/kagan/github/kagan-sh/kagan/-/custom-context?memory=88a14340-9a15-4297-925d-3656d144ad2a))
How can I resolve this? If you propose a fix, please make it concise.| } | ||
|
|
||
| async function removeStaleMarker(fs: FileSystem, markerPath: string, marker: UpdateMarker) { | ||
| if (marker.prepared) await fs.rm(marker.prepared, { recursive: true, force: true }).catch(() => {}) |
There was a problem hiding this comment.
Stale Marker Controls Deletion
When a marker parses but does not match the current layout, this branch recursively removes marker.prepared before validating that it is Kagan’s expected prepared wrapper. A stale marker from another target or a malformed marker can point outside the intended cache state, causing startup cleanup to delete the wrong directory.
Context Used: AGENTS.md (source)
Artifacts
- Contains supporting evidence from the run (text/typescript; charset=utf-8).
Repro: execution log showing before and after filesystem evidence for sentinel deletion
- Keeps the command output available without making the summary code-heavy.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tui/update-cleanup.ts
Line: 40
Comment:
**Stale Marker Controls Deletion**
When a marker parses but does not match the current layout, this branch recursively removes `marker.prepared` before validating that it is Kagan’s expected prepared wrapper. A stale marker from another target or a malformed marker can point outside the intended cache state, causing startup cleanup to delete the wrong directory.
**Context Used:** AGENTS.md ([source](https://app.greptile.com/kagan/github/kagan-sh/kagan/-/custom-context?memory=88a14340-9a15-4297-925d-3656d144ad2a))
How can I resolve this? If you propose a fix, please make it concise.| try { | ||
| return JSON.parse(await fs.readFile(markerPath, "utf8")) as UpdateMarker | ||
| } catch { | ||
| await fs.rm(markerPath, { force: true }) |
There was a problem hiding this comment.
Corrupt Marker Blocks Recovery
If the process exits after current was renamed to backup and the marker is left malformed, this catch deletes the only recovery marker and returns undefined. The interrupted-promotion branch never runs, current stays missing, and each launch can remain stuck in updates unavailable instead of restoring from the validated backup.
Context Used: AGENTS.md (source)
Artifacts
Repro: focused Bun harness for corrupt marker crash-recovery state
- Contains supporting evidence from the run (text/typescript; charset=utf-8).
Repro: command output showing marker deletion with current still missing and backup still present
- Keeps the command output available without making the summary code-heavy.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tui/update-cleanup.ts
Line: 18-21
Comment:
**Corrupt Marker Blocks Recovery**
If the process exits after `current` was renamed to `backup` and the marker is left malformed, this catch deletes the only recovery marker and returns `undefined`. The interrupted-promotion branch never runs, `current` stays missing, and each launch can remain stuck in `updates unavailable` instead of restoring from the validated backup.
**Context Used:** AGENTS.md ([source](https://app.greptile.com/kagan/github/kagan-sh/kagan/-/custom-context?memory=88a14340-9a15-4297-925d-3656d144ad2a))
How can I resolve this? If you propose a fix, please make it concise.Finding 1: removeStaleMarker only deletes marker.prepared when it is a kagan@<x.y.z> sibling of the current wrapper in the same scope cache; a corrupted marker naming any other path now leaves that path alone. Finding 2: removed dead restoreCurrentFromBackup / interruptedPromotion branch (the host re-downloads latest before the plugin loads, so it can never run), aligned R18.8 and design.md to the real contract, and closed the prepared-dir leak in the matched-marker cleanup path. Finding 3: prepare failure on a ready check now sets broken status and suppresses the ready toast instead of promising a restart that applies nothing; a failed cleanup now returns broken before the network check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the three review findings. Finding 1 (HIGH) — unvalidated recursive delete. Finding 2 (MED) — dead restore path, dishonest spec, prepared-dir leak.
Finding 3 (MED) — prepare failure showed a false promise.
|
references/opencode is git-ignored, so CI checkouts never have it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4d9bdad
into
fix/test-hermeticity-structural
* test: make git hermeticity structural via preload * test: rename hermeticity wording to git isolation * test: wait for the merge-conflict notice instead of asserting notice order * test: allow slow CI git spawns to deliver the merge-conflict notice * fix: make update promotion crash-safe and trim speculative machinery (#21) * fix: make update promotion crash-safe and trim speculative machinery Restore interrupted promotions on launch, self-heal stale cache state, surface local update failures in the footer, and remove unused concurrency/path checks. * fix: harden update cleanup against unvalidated deletes and false ready Finding 1: removeStaleMarker only deletes marker.prepared when it is a kagan@<x.y.z> sibling of the current wrapper in the same scope cache; a corrupted marker naming any other path now leaves that path alone. Finding 2: removed dead restoreCurrentFromBackup / interruptedPromotion branch (the host re-downloads latest before the plugin loads, so it can never run), aligned R18.8 and design.md to the real contract, and closed the prepared-dir leak in the matched-marker cleanup path. Finding 3: prepare failure on a ready check now sets broken status and suppresses the ready toast instead of promising a restart that applies nothing; a failed cleanup now returns broken before the network check. * test: skip the host-dedupe pin when the vendored source is absent references/opencode is git-ignored, so CI checkouts never have it. ---------
* fix: enforce all verifyx checks and check-only pre-commit Install knip, skott, and jscpd so verifyx all runs every built-in gate, dedupe status resolution and break git/domain layering cycles flagged by the new checks, and make the pre-commit hook check-only so staged content is never rewritten. * test: make git isolation structural via test preload (#20) * test: make git hermeticity structural via preload * test: rename hermeticity wording to git isolation * test: wait for the merge-conflict notice instead of asserting notice order * test: allow slow CI git spawns to deliver the merge-conflict notice * fix: make update promotion crash-safe and trim speculative machinery (#21) * fix: make update promotion crash-safe and trim speculative machinery Restore interrupted promotions on launch, self-heal stale cache state, surface local update failures in the footer, and remove unused concurrency/path checks. * fix: harden update cleanup against unvalidated deletes and false ready Finding 1: removeStaleMarker only deletes marker.prepared when it is a kagan@<x.y.z> sibling of the current wrapper in the same scope cache; a corrupted marker naming any other path now leaves that path alone. Finding 2: removed dead restoreCurrentFromBackup / interruptedPromotion branch (the host re-downloads latest before the plugin loads, so it can never run), aligned R18.8 and design.md to the real contract, and closed the prepared-dir leak in the matched-marker cleanup path. Finding 3: prepare failure on a ready check now sets broken status and suppresses the ready toast instead of promising a restart that applies nothing; a failed cleanup now returns broken before the network check. * test: skip the host-dedupe pin when the vendored source is absent references/opencode is git-ignored, so CI checkouts never have it. --------- * refactor: simplify update marker and cleanup machinery * refactor: merge single-importer modules * refactor: extract duplicated TUI blocks * chore: drop redundant verify overrides * ci: run check workflow on pull requests only * refactor: inline single-use list-editor row wrapper renderListEditorRows only forwarded its five arguments to <ListEditorRows> and had a single caller; render it directly and drop the now-unused JSX import. * test: make merge-dialog command tests deterministic across file order The two "approving …" tests in test/tui/board/commands.test.ts flaked only on CI. Root cause: bun's mock.module is process-global and persistent, and test/tui/session/tasks.test.ts mocks src/git/runner (currentBranch -> "kagan/x") and src/git/merge (mergeTaskBranch -> ok:true). ESM imports hoist above mock.module, so whichever test file loads first wins; on the Linux runner tasks.test.ts loaded first, so the merge-dialog tests saw the leaked mocks (wrong branch, no conflict) and failed. macOS load order hid it locally. - commands.test.ts now declares its own git/runner + git/merge mocks and drives the three merge-dialog tests through reset-per-test vars (currentBranchValue, localBranches, mergeResult), so its values win for its own tests regardless of which file ran first. - tasks.test.ts's git/runner mock was incomplete (missing listLocalBranches and baseBranchFreshness); stub them so binding those exports elsewhere while the mock is active no longer throws "Export not found". - The merge dialog's onSelect returns its handler promise so tests await the real work instead of racing a render/timer. Reproduced the exact leak order locally (bun test tasks.test.ts commands.test.ts) and via a Linux-container full-suite run; green there, commands-only, and reverse. * fix: surface both promotion and restore errors on update rollback promotePreparedUpdate runs in api.lifecycle.onDispose, whose thrown errors the host logs via console.error rather than discarding. Swallowing a failed restore therefore hid it: a lost kagan@latest wrapper logged only as a promotion failure. Rethrow an AggregateError carrying both, and add a test covering the double-failure path (the single-failure restore was already tested).
Summary
cleanupPreparedUpdaterestoresbackup → currentwhen a marker exists butcurrentis missing after an interrupted promotion; stale marker and prepared dirs are removed afterward.prepareUpdate; cleanup failures surface{ kind: "broken" }in the board footer instead of silent swallowing.checkForUpdateWeakMap dedup (~43 test lines) and symlink/hardlink/nlink/realpath-escape checks fromupdate-paths.ts; kept layout basename checks andvalidateWrappermanifest verification.runAutomaticUpdateLaunchwith per-step failure isolation and direct tests; pinned hostplugins.addid-dedupe viatest/guards/host-update.test.ts.Deleted update-paths checks (threat-model reasoning)
isSymbolicLink()on cache dirs/markerrename/rmdo not follow symlinks at the destination.nlink === 1on markerrealpathescape checkKept:
updatePathslayout/basename validation andvalidateWrapperdirectory +package.jsonname/version verification.Crash recovery test — fails before fix
With
restoreCurrentFromBackupdisabled, the test throws on missingcurrent:With fix enabled:
Acceptance verification
Spec/doc edits
updates unavailablefooter.Test plan
prepareUpdatesucceedstest/tui/update-launch.test.ts)runtime.tsbun run verifyMade with Cursor