Skip to content

fix: unarchive keeps a thread active, kept worktrees survive the sweep, delete cascades to descendants - #609

Merged
Tryanks merged 5 commits into
mainfrom
fix/phase0-lifecycle-bugs
Oct 6, 2026
Merged

Tryanks merged 5 commits into
mainfrom
fix/phase0-lifecycle-bugs

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Phase 0 of #535, one commit per bug. Closes #515, #516, #518.

#515 — unarchiving a thread refreshes its activity time

unarchive_session stamps updated_at = now on the root and every cascade member it restores, so auto_archive_candidates no longer takes an idle thread straight back; a thread that had been read keeps last_visited ahead of the new updated_at so no false unread dot appears. The thread moves to the top of its list (accepted). Test: unarchive through the pipe, then AutoArchiveSweep acks ArchivedCount(0); fails before the fix.

#516 — the orphan sweep leaves worktrees the user kept

Correcting the issue: forks sharing a deleted source's cwd were already protected, and removal was never forced. The real gap was a clean worktree deleted with "Keep": nothing recorded the choice, so the sweep removed it after an hour. A kept_worktrees table (created on open if missing, no schema version change) records the directory in the same transaction as the delete, and the sweep skips it. A second cause found in the area: worktrees were always provisioned in and swept from ~/.tcode/worktrees regardless of the data dir, so a TCODE_DATA_DIR profile swept the default profile's worktrees; they now live in <data dir>/worktrees and each store sweeps its own (TCODE_WORKTREES_DIR removed; the default data dir is ~/.tcode, so nothing moves for default users). Test: a deleted-with-keep worktree and a fork's shared worktree survive the sweep while a genuine orphan is removed; fails before the fix.

#518 — deleting a thread deletes every thread under it

Delete cascades over the same descendant tree archive uses: every member's provider and background work is stopped, registrations closed, and all metas and event logs removed in one store transaction (StoreWrite::RemoveSessions). Worktrees follow the single remove_worktree flag; with it set, a worktree is removed only when no surviving thread works in it. descendant_session_ids moved to tcode-core (O(n)) and is shared by the runtime and the client's count. The confirmation dialog states the count when there are descendants; the client computes it from its index replica plus the archived list (Query::ArchivedSessions, which it already uses), because orchestrated children are usually auto-archived — no wire change. Sidebar delete and Settings → Archived → Delete now share one dialog. New string sidebar.delete_tree_description in both locales. Tests: delete a parent through the pipe, the parked grandchild's provider gets Shutdown, the reopened store holds only the unrelated thread; the client's count includes archived descendants.

Looked at in the app on a scratch data dir: light/dark, 1200 pt and 426 pt, en and zh-CN.

Delete all archived (fourth commit)

The confirmation now states the archived threads and the unarchived threads under them that the cascade takes (new strings in both locales when that count is non-zero), and the delete set is deduplicated: one DeleteSession per archived root, so a thread that is both archived and under another archived thread is neither counted nor deleted twice. Test: archived parent + unarchived child → (1, 1) and exactly one delete; archived parent + archived child → (2, 0) and exactly one delete; fails with dedup disabled. Looked at in the app in both themes and languages, wide and narrow.

Known edges, left as they are

  • The worktree prompt is still root-only: if only descendants own worktrees there is no prompt, they are kept and recorded as kept.
  • The kept list only grows (paths are per-session UUIDs, so stale rows are harmless).

Checks run

cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo nextest run --workspace --locked (949 passed, 11 skipped), cargo machete, Web and iOS checks, locale parity. Not run: Android, Windows, Linux.

  • This change alters the wire protocol, so the next release needs a
    PROTOCOL_VERSION bump: a note was added under "Unreleased" above the
    constant in crates/protocol/src/lib.rs (the number itself changes only
    when the release is cut — CONTRIBUTING.md, principle 9).

unarchive_session now stamps updated_at = now on every thread it restores
(the root and the cascade members carrying its archive time), so the
auto-archive sweep no longer takes an idle thread straight back. A thread
that was read before it was archived has its last-visited mark advanced
with it, so the restore does not light an unread dot.
Deleting a thread with its worktree kept (the "Keep" choice, Settings →
Archived Threads → Delete all, deleting a project) now records the
worktree in a kept_worktrees table, committed in the same transaction as
the thread's removal. The startup sweep treats a kept worktree like one a
thread works in and leaves it, however old and clean it is.

The sweep now belongs to the store: cleanup_orphans(store) reads the
store's threads (every cwd, so a fork living in a deleted source's
worktree keeps it) and its kept list, and sweeps the store's own
<data dir>/worktrees. Worktrees were provisioned in and swept from
~/.tcode/worktrees whatever the data dir, so a TCODE_DATA_DIR or
--data-dir profile swept the default profile's worktrees as orphans.
For the default data dir (~/.tcode) the location is unchanged.
TCODE_WORKTREES_DIR, which only a test used to isolate itself, is gone;
that test now checks the worktree lands in its store's data dir.

The table is created on open when missing, without a schema version
change: an older build ignores it and loses nothing.
delete_session now deletes the thread and its whole descendant tree (the
tree archive and unarchive act on) in one command: every member's
provider, background work, terminals, approvals and MCP registrations are
closed, and all of their metas and event logs are removed in one store
transaction (StoreWrite::RemoveSessions), together with the kept-worktree
records. Worktrees owned in the tree follow the same remove_worktree
choice; one is removed only when no thread outside the tree works in it.
Deleting a project deletes the union of its threads' trees the same way.

descendant_session_ids moves to tcode_core::project so the host's cascade
and the client's count are the same tree.

The confirmation (sidebar and Settings → Archived Threads) states how many
threads go when there are descendants (sidebar.delete_tree_description,
en and zh-CN). The client counts from its index replica plus the archived
threads, which it holds on the Archived page and otherwise fetches once
with the existing ArchivedSessions query, since orchestrated children are
often archived and absent from the index. The wire is unchanged.
… takes

Deleting a thread deletes every thread under it, so Settings → Archived
Threads → Delete all also removes threads that were unarchived under an
archived parent, while the confirmation only named the archived ones.

WorkspaceStore::archived_deletion computes, from the index replica and the
held archived threads (descendant_session_ids, as the single-thread
confirmation does), how many listed archived threads go and how many
unarchived threads under them go with them. When any unarchived thread is
taken, the dialog says so and gives the total
(settings.archived_delete_all_tree_title/_description, en and zh-CN);
otherwise the existing text stays. delete_archived sends one DeleteSession
per archived thread with no archived ancestor, so a thread that is both
archived and under another archived thread is neither counted nor deleted
twice.

delete_all_archived_counts_unarchived_descendants_once drives the store as
the Archived page does and asserts the counts and the DeleteSession
commands on the wire; without the dedup it sees ["child", "parent"].
@Tryanks
Tryanks enabled auto-merge October 6, 2026 15:00
…e events

classifier_stop_and_review_preserve_diagnostics_until_the_next_turn waited
only for the status replica, so the injected TurnStarted could reach the
host before the SessionEvents subscription existed and was dropped. The app
applies live session events only after that baseline (baseline_ready gates
Connected), so the driver now waits for it.
@Tryanks

Tryanks commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

Linux x64 failed store::tests::classifier_stop_and_review_preserve_diagnostics_until_the_next_turn (timeout). Settled at step 1 of the failing-test procedure: a driver fault that predates this branch. The test sent its live TurnStarted after waiting only for the status replica, i.e. possibly before the SessionEvents subscription was active, in which case the event is dropped (host-side it is emitted onto the bus, not logged; client-side live events are ignored until the events baseline) and the final wait can never succeed. Reproduced on main under CPU load (4/300), not at all after the fix (600/600). The driver now waits for baseline_ready(), as the app applies live events only after the baseline; no assertion or production code changed.

@Tryanks
Tryanks merged commit 381249d into main Oct 6, 2026
7 checks passed
@Tryanks
Tryanks deleted the fix/phase0-lifecycle-bugs branch October 6, 2026 16:11
Sign up for free to 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.

Unarchived threads are re-archived by the next auto-archive sweep

1 participant