Skip to content

fix(desktop): keep a remove from destroying a restored task - #3056

Merged
M4n5ter merged 2 commits into
mainfrom
fix/desktop-remove-requires-archived
Aug 16, 2026
Merged

fix(desktop): keep a remove from destroying a restored task#3056
M4n5ter merged 2 commits into
mainfrom
fix/desktop-remove-requires-archived

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Deleting a task that was concurrently restored deleted it permanently anyway. The Host-side atomicity that prevents this already exists — session.remove and session.lifecycle.set both enter #withStableFamily, which queues per Session id through the admission gate, and the remove compares the revision on the way in. A restore therefore either lands before that comparison, bumping metadataVersion and rejecting the remove, or waits until the retirement has finished. It cannot land between the check and the delete.

The Desktop Client threw that rejection away. removeSession treated the conflict as a stale read and replayed the delete at the fresh revision, up to eight times. Replay is right for a rename or a configuration patch — the write means the same thing either way. It is wrong for a remove: the conflict is the signal that the task was touched after the caller decided to destroy it, so the replay executed a destruction whose premise no longer held, and the retry widened the race from the gap between two calls to the whole loop.

removeSession now takes the premise the caller decided on. With requireArchived, each fresh read re-asserts that the task is still archived and answers restored instead of replaying. Single-row delete carries the premise of the row it was raised from, so deleting an active task from the rail is unaffected, as is copy cleanup, which states no premise.

The bulk sweep in 设置 › 活动 › 已归档任务 used to decide an id's fate in two places: it skipped anything its own catalog snapshot no longer showed as archived, and separately reported what the Host answered restored. That snapshot is one observer of state the Host owns, a second window can outdate it in either direction, and it cannot tell a restored task from one already deleted elsewhere — so the filter is gone rather than taught to classify. Every id now goes to the delete, which already distinguishes all four outcomes: still archived removes, restored answers restored, already gone rejects and settles as removed against the catalog, anything else is an error with a reason. The toast reports failures and kept tasks independently instead of choosing which single fact to say.

No Runtime Host or protocol change: the serialization and compare-and-set this relies on are already there. The issue's original "Why this belongs to the Host" and "Proposed direction" sections proposed an expected-lifecycle field on session.remove; that is over-built while Desktop is the only consumer, and is being corrected separately. It becomes the right call when a second client can delete — see the follow-up note below.

Fixes#3050

Verification

Reproduced first on unmodified code, then re-run after the fix:

  • apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts — the whole main-process chain over real UDS framing. A restore lands between the Client's read and its remove; on main the sweep deleted the task (actual: undefined, expected: 'restored', and the catalog came back empty), and now it answers restored, the task is still listed as active, and no deleted retirement is emitted for it.
  • apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts — the retry loop itself: abandons on a restore, still retries through churn that left the task archived, still removes a task that was never archived.
  • apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts — every id reaches the delete rather than being filtered against the renderer's snapshot; restored tasks are reported apart from removed and failed and keep their renderer state; single-row delete carries the premise of its own row. Mutation-checked: restoring the snapshot filter turns three of these red.

Ran locally: those suites plus app-shell-session-row-actions-revisions (35 tests, all passing), npm --workspace apps/desktop run typecheck, and Biome lint over apps/desktop/src and apps/desktop/stories. Note npm run format is a no-op here — the formatter excludes apps/desktop/** (biome.jsonc); lint does cover these files. Not run: the repository-wide suite, E2E, and Storybook visual smoke — left to CI. The two-window GUI repro described in the issue was not performed by hand; the UDS test above stands in for it at the seam where the defect lives.

Review focus

Data deletion — this needs independent human review, no self-merge fast path.

Two judgement calls worth checking:

  1. requireArchived is the caller's to state rather than something removeSession assumes. An active task deleted from the rail, and revision-copy cleanup, have no archived premise to lose; requiring one would break both.
  2. The sweep no longer pre-filters against the renderer's catalog. This costs one IPC round trip per already-restored task and removes the second source of truth that made the previous reporting incomplete.

Known gaps

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@coderabbitai

coderabbitaiBot commented Aug 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 970a504b-7597-4640-b828-47e16930ef12

📥 Commits

Reviewing files that changed from the base of the PR and between c15faab and f0fa5fe.

📒 Files selected for processing (4)
  • apps/desktop/src/preload/bridge-contract.d.ts
  • apps/desktop/src/preload/preload.ts
  • apps/desktop/src/renderer/settings/tasks-settings-page.tsx
  • apps/desktop/stories/settings/settings-pages.stories.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/desktop/src/preload/bridge-contract.d.ts
  • apps/desktop/src/renderer/settings/tasks-settings-page.tsx
  • apps/desktop/src/preload/preload.ts
  • apps/desktop/stories/settings/settings-pages.stories.tsx

Included review availability: Your plan includes up to 3 reviews per rolling hour; 2 remain after this review.


📝 Walkthrough

Summary

  • Problem solved

    • Prevents deletion of a task restored during concurrent removal.
    • removeSession re-checks the archived-state premise after revision conflicts.
    • Returns restored when the task no longer satisfies that premise.
    • Reports restored tasks separately during purge.
    • Preserves active-task deletion and revision-copy cleanup.
  • Source of truth

    • Extends the existing Desktop Client, preload bridge, and Runtime Host compare-and-set flow.
    • Does not create a parallel deletion path.
    • Does not change the Runtime Host protocol.
  • Solution size and complexity

    • This is the smallest coherent solution for conditional removal.
    • The new disposition and requireArchived option are required to preserve restored tasks and report the result.
    • The added tests cover revision conflicts, retries, UDS behavior, purge outcomes, renderer-state preservation, and single-row deletion.
  • Simplification opportunities

    • No safe deletion or simplification is evident without weakening restored-task handling or regression coverage.
  • Validation

    • Affected tests, typecheck, and lint pass locally.
    • Repository-wide tests, E2E tests, and Storybook visual smoke tests were not run.
    • Required checks remain unverified without direct CI evidence.

Review-relevant risks

  • The preload bridge contract changes from Promise<void> to Promise<'removed' | 'restored'> and adds requireArchived. Material public-contract changes require independent human review under repository policy.
  • User-visible deletion and purge notifications now distinguish restored tasks. Material user-visible behavior changes require independent human review under repository policy.
  • No security, licensing, release, or governance effect was identified in the current diff.
  • The person performing the merge reviews the final diff. A maintainer makes the final determination.

Walkthrough

Session removal now requires an archived-state precondition when requested. The desktop stack returns removed or restored, preserves concurrently restored sessions, reports restored IDs during purge, and updates related tests and notifications.

Changes

Archived session removal

Layer / File(s)Summary
Runtime removal semantics
apps/desktop/src/main/runtime-host-client.ts, apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
removeSession checks the current archived state, returns restored after a conflicting restore, retries when the session remains archived, and returns removed after deletion.
IPC removal contract
apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts, apps/desktop/src/preload/bridge-contract.d.ts, apps/desktop/src/preload/preload.ts
IPC validates and forwards requireArchived, performs cleanup only for actual removals, and exposes the removal disposition through the preload bridge.
Renderer deletion and purge flow
apps/desktop/src/renderer/app-shell-session-row-actions.ts, apps/desktop/src/renderer/settings/tasks-settings-page.tsx, apps/desktop/src/renderer/locales/*, apps/desktop/stories/settings/settings-pages.stories.tsx
Renderer deletion preserves restored sessions, tracks restored purge IDs, updates purge outcomes, and displays localized restored-session messages.
Removal flow validation
apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts, apps/desktop/src/main/__tests__/app-shell-session-row-actions-revisions.test.ts, apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
Tests cover archived requirements, concurrent restoration, revision retries, preserved renderer state, returned dispositions, and deletion-event behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk:⚪ Minimal · up to f0fa5

The desktop deletion flow now preserves tasks restored during removal and reports bulk-delete outcomes correctly; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
participant User
participant Renderer
participant PreloadBridge
participant IPCHandler
participant RuntimeHostClient
User->>Renderer: purge archived sessions
Renderer->>PreloadBridge: remove(sessionId, requireArchived)
PreloadBridge->>IPCHandler: invoke session removal
IPCHandler->>RuntimeHostClient: removeSession(sessionId, requireArchived)
RuntimeHostClient-->>IPCHandler: removed or restored
IPCHandler-->>PreloadBridge: removal disposition
PreloadBridge-->>Renderer: removal disposition
Renderer-->>User: deletion or restored-session notification
Loading

Possibly related PRs

Suggested reviewers:liugddx, m4n5ter, likun666661

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Ai Use Disclosure⚠️ WarningThe description selects neither AI-use declaration and does not disclose Claude Code, while both introduced commits contain Generated-by: Claude Code.Select the generative-tooling option, name Claude Code and its scope, and review “Human ownership and AI attribution” in CONTRIBUTING.md; preserve trailers through squash or amend.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary fix: preventing deletion of a task restored concurrently.
Description check✅ PassedThe description covers the summary, issue link, verification, review focus, known gaps, and checklist; the AI use section is omitted.
Linked Issues check✅ PassedThe changes satisfy #3050 by returning restored, preserving restored tasks, reporting purge outcomes, and leaving active deletion unaffected; #39 is unrelated context.
Out of Scope Changes check✅ PassedThe code and tests remain focused on Desktop Client session removal and purge behavior required by #3050, with no unrelated functional changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/desktop-remove-requires-archived

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

@coderabbitai

Copy link
Copy Markdown

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.

Deleting a task is a read of the catalog followed by a remove, and the
Host already refuses the remove when the record changed underneath: the
retirement coordinator compares the revision inside its admission lock,
and the metadata store re-checks the version in the same transaction as
the DELETE. Unarchiving writes through that same header, so a concurrent
restore reliably rejects the delete.
The Desktop Client threw that rejection away. `removeSession` treated a
`revision_conflict` as a stale read and replayed the delete at the fresh
revision, up to eight times. Replay is right for a rename or a
configuration patch, where the write means the same thing either way. It
is wrong for a remove: the conflict is the signal that the task was
touched after the caller decided to destroy it, so the replay executed a
destruction whose premise was gone — and the retry widened the race from
the gap between two calls to the whole loop. The bulk purge in
设置 › 活动 › 已归档任务 made it easy to hit, because a serial sweep holds
that window open for every task in it.
`removeSession` now takes the premise the caller decided on. With
`requireArchived`, each fresh read re-asserts that the task is still
archived and answers `restored` instead of replaying. That is enough to
hold the premise through the commit: the version check and the DELETE
share one transaction, so a remove committing at the revision just read
is a remove of the record that was read as archived.
`restored` is not a failure and is reported apart from one. A sweep does
not count it as removed, does not check it back against the catalog, and
names it in the toast rather than quietly deleting fewer tasks than the
confirm agreed to. Single-row delete carries the premise of the row it
was raised from, so deleting an active task from the rail is unaffected.
No Runtime Host or protocol change: the compare-and-set that makes this
work is already there.
Fixes#3050
Generated-by: Claude Code
The sweep decided an id's fate in two places. It skipped anything its own
catalog snapshot no longer showed as archived, and separately reported
what the Host answered `restored`. The renderer's list is written
synchronously as the sweep runs, so the skip is the path a restore
usually takes — and it dropped the id with no outcome at all: not
removed, not restored, not remaining. A person who confirmed five and got
"deleted two" was told nothing about the other three. The reporting added
for that only covered the narrow window where a restore lands after the
snapshot read and before the Host commits.
Deciding here was never sound. The snapshot is one observer of state the
Host owns, a second window can outdate it in either direction, and it
cannot tell a restored task from one already deleted elsewhere. So the
filter is gone rather than taught to classify: every id goes to the
delete, which already distinguishes all four outcomes. Still archived
removes; restored answers `restored`; already gone rejects and settles as
removed against the catalog, which is what the rejected-id check has
always done; anything else is an error with a reason. One path, one
outcome, one source of truth.
The purge toast stopped choosing which single fact to report. Failures
and kept tasks are independent, so a sweep that hit both said only the
first and silently dropped the second — and an unverified sweep claimed
"the tasks were deleted" over tasks it had deliberately kept. Kept tasks
are now a fragment that reads after either outcome.
Also corrects the reasoning in `removeSession`'s contract. Re-asserting
the premise holds through the commit because the Host serializes
`session.remove` and `session.lifecycle.set` through the same per-Session
admission queue, not because the client's revision reaches the DELETE
transaction — the coordinator refreshes family records inside that lock
and commits with those versions. The conclusion was right, the mechanism
named was not, and the version check alone would not have carried it.
The remove options now refuse a shape they cannot read instead of
falling through to "no premise stated", which is the destructive answer,
and no longer depend on a sibling validator running first.
Refs #3050
Generated-by: Claude Code
@M4n5ter
M4n5terforce-pushed the fix/desktop-remove-requires-archived branch from c15faab to f0fa5feCompareAugust 16, 2026 12:40

@M4n5terM4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. Re-reviewed after rebasing onto the latest main; the conflict resolution preserves the host-aware session routing and the restored-task deletion guard. Targeted tests, Desktop typecheck, and Biome checks pass.

@M4n5ter
M4n5ter merged commit 352e8ca into mainAug 16, 2026
10 of 11 checks passed
@M4n5ter
M4n5ter deleted the fix/desktop-remove-requires-archived branch August 16, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Removing a session should require it to still be archived

2 participants

@Astro-Han@M4n5ter