Conversation
Add one opt-in, per-Host setting that deletes archived tasks after 30, 60
or 90 days. It is off by default; the Host decides and deletes, and
Desktop shows the setting and what the last sweep did.
Setting. A dedicated Host document, archive-retention.json in the State
Root, holds { version, revision, enabled, days, enabledAt, observedAt,
latest }. It is not part of the runtime policy, agent settings or config
export/import; the only writer is the new storage.retention.set command.
enabledAt is stamped by the Host clock and re-stamped by any change
(enabling, or new days while enabled), so enabling never deletes a
backlog and shortening never deletes at once; disabling clears it. A
revision CAS rejects stale writes. A document that cannot be fully
validated, including one from a newer Host, reads as disabled.
Sweep. A new HostStorageMaintenance lane, started after Ready, runs one
bounded step a second while work remains and every 15 minutes otherwise,
deleting at most 8 revision families a tick. No SQL runs before
enabledAt + days. Candidates are the rows Settings > Archived tasks shows
(archived roots and orphaned archived subtasks, no graph operators, no
preparing copies) in a family no member of which is pinned, oldest first,
with a keyset cursor so kept families do not starve the rest. The
existing (is_flagged, is_archived, ...) index bounds the scan; no
migration.
Deletion goes through session.remove's own path: #remove becomes
removal admission, after the plan is stable and before any retirement
work. It rechecks the policy (no pending change, same revision, days and
enabledAt), that every member is archived and unpinned, and that the
family's newest clock start, max(archivedAt ?? enabledAt, enabledAt), is
more than `days` old. A family whose removal would archive an active
subtask or reclaim a subagent worktree is kept as needs-review. The
existing busy guards apply unchanged and count as skipped-busy. Manual
session.remove is unchanged.
Clock. Wall time with two guards: the Host keeps the latest time it has
observed (persisted with the document), and a sweep pauses, records the
pause once and deletes nothing while the clock reads earlier than that or
than the newest committed_at/archived_at in session_metadata.
Results are latest-only: lastSweep { at, deleted, skippedBusy,
needsReview, failed, paused? } and lastDeletion { at, count, bytes? },
with bytes measured before deletion as the batch preview measures them.
The document is written only when a sweep changed something, and logs
carry counts only.
Protocol (epoch 202 -> 203): storage.retention.query returns the setting,
a Host preview (candidate families and when the first becomes eligible;
previewDays previews enabling or changing days now) and the latest
results; storage.retention.set { expectedRevision, enabled, days }
answers committed or revision_conflict. Both are renderer pass-through
and remote-owner operations.
Desktop. Settings > Archived tasks gains an Automatic cleanup section
for the selected Host (the page now shows the Host picker): a switch, the
period, the Host preview, the last automatic cleanup with its size, a
needs-review count and a paused notice. Enabling or changing the period
asks a confirm that states the Host preview. The copy says the setting
covers every archived task, starts its clock when enabled, keeps pinned
tasks and deletes permanently. The legacy page only renders the feature
section.
Refs apache#5899
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Address the adversarial review of the opt-in retention commit.
Safety
- A setting change now waits for the sweep step in flight. It marks
itself pending first, so the guard admits nothing new and the loop
stops before the next family; the family already admitted finishes
and is recorded, and only then does storage.retention.set commit.
Once set answers, no deletion admitted under the old setting runs.
- A pass records its results only under the setting revision it
started with.
- Draining stops a sweep before the next family.
- enabledAt is max(now, the observed high-water, the newest metadata
time), so enabling behind a clock that went back cannot backdate the
deadline; any setting change clears a recorded pause.
- A candidate row that no longer decodes is returned as such, counted
as failed once per pass and passed over, so it cannot wedge a sweep.
One authority
- The guard's worktree check uses the count the removal preview uses
(one shared helper, so no worktree executor means none reclaimed).
- readSessionArchiveTimes is gone; the guard reads archivedAt through
readCatalogRecord, the reader the manual age guard uses.
- The archived-task row is one SQL predicate next to the catalog's
visibility predicate, which the catalog page query now shares.
Retention joins session_catalog_projection, and "orphaned" means the
parent is not a catalog-visible Session, as the rail treats it. A
Desktop test checks the candidates equal archivedTaskRows on a mixed
fixture.
- Sweep and deletion records have one decoder in core, used by the
State Root document and the protocol.
Simpler
- previewDays is gone. The query always returns preview { count,
eligibleAt? }; eligibleAt is present exactly while enabled with
candidates. Desktop states "at least N" and "no earlier than about
<its own now + days>" in the confirm.
- The preview is one aggregate query in storage.
- observedAt is no longer persisted; after a restart the floor is
enabledAt, the last sweep time and the newest metadata time.
- The guard compares the setting revision only.
- The section names the Host it applies to.
Refs apache#5899
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Address the review of the opt-in retention for archived tasks.
Forward clock jump. A wall clock set far ahead (bad RTC or NTP, VM
restore, a manual date change) could make a whole backlog eligible at
once. A sweep now holds deletions for 24 hours when the clock moved
ahead of the last time the Host observed by more than the smaller of
the retention window and 7 days. In a running Host that reference is
the in-memory high-water; after a restart it is the persisted floor
(enabledAt, the last sweep, a previous hold) and, once past the
deadline, the newest Session metadata time, which says when the Host
last ran. The hold is recorded as latest.hold { since, detectedAt,
until }, a further jump re-arms it, it is cleared once the clock
reaches `until`, and any setting change clears it. No uptime or
monotonic clock is used: a sleeping laptop would read as a jump, and a
credited clock was rejected in apache#5899. Desktop shows when cleanup
resumes and how far the clock moved, and suggests turning cleanup off
if the time is wrong.
Preview wording. The count includes families a sweep keeps (busy, for
review) and misses subtasks a deletion orphans later, so it is neither
bound; the copy now says "N archived tasks are subject to automatic
cleanup" in all three locales, in the section and the confirm.
Imported archived tasks. A Session bundle copies session_metadata rows
verbatim, so an archived task arrived with the archive time (or none)
of the machine it left and could be deleted right after import. The
import now stamps archived_at with the import time for every imported
archived Session.
Refs apache#5899
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ata write A Host restarted more than 7 days after enabling retention, but before the deadline, measured the forward gap from the persisted floor alone (effectively enabledAt). It recorded a false clock-jump hold even when the app had been in use a minute earlier, and because a hold was only cleared after the deadline and always reported, its banner stayed up until then. - A fresh process now measures the gap from the later of the persisted floor and the newest Session metadata time, before and after the deadline alike: one MAX query, once per process. No candidate is read before the deadline. - A hold expires on its own: once the clock reaches `until` it is cleared with one write, whatever the deadline, and the query never reports a hold whose day is over. Refs apache#5899 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The retention candidate predicate excluded pinned families with a correlated NOT EXISTS over session_metadata. No index covers is_flagged (migration 26 dropped session_metadata_by_flag, which the old comment still cited), so every candidate rescanned the table. The cost was quadratic and ran synchronously on the Host thread, for both the sweep page and the Settings preview count. Synthetic timings: about 47 ms at 2k Sessions, 1.1 s at 10k, 20 s at 40k. Use an uncorrelated NOT IN over the pinned family roots. SQLite builds it once per statement: 2 ms at 2k Sessions, 4 ms at 10k, 21 ms at 40k. The result set is identical (checked on the synthetic data). The family root is COALESCE(..., session_id), and session_id is NOT NULL, so NOT IN has no NULL pitfall. The parent check in the row predicate stays correlated: it is a primary-key lookup. Refs apache#5899 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
Review of exact head c682972c.
This PR is stacked on #5902. Its 8 commits are #5902's 7 (c1caceb2...717841a0, the same SHAs) plus c682972c "notify about archived-task cleanup outside Settings", and c682972c^ == 717841a0. This review covers only c682972c: 13 files, +620/-17. #5902 is reviewed separately. This PR can only merge after #5902, and it inherits #5902's epoch-205 collision with #5709.
What it does. ArchiveRetentionNotices is mounted in the app shell. While the window is visible and focused, it polls storage.retention.query on every enabled, ready Host profile, once a minute and again on profile changes or focus/blur/visibility changes. It shows three kinds of toast:
- an info toast for a new
lastDeletion, throttled to one per Host every 15 minutes by Client time; - a sticky warning toast for a hold;
- a sticky warning toast for a backward-clock pause.
Results are identified by Host time and stored in localStorage per hostId. When the Settings retention section loads, it acknowledges the results it showed, so they are not announced again.
Correctness. I found no P0-P2 issues.
- Observing never starts a Host: only
enabled && readiness === 'ready'entries are polled. - A Host whose query fails is skipped, and the other Hosts are still checked.
- When the Host list changes or the observer is disposed mid-flight, late results are discarded (
version/closedchecks). - Duplicate profiles for one
hostIdare queried once. - Warnings are cleared when the Host clears them or the Host goes away.
- Corrupt localStorage decodes to
{}, so it cannot suppress notices. - The toast action goes to
archived-tasks, which rendersArchiveRetentionSection(tasks-settings-page.tsx:92,101). - The services object is created once, so the
useEffect([services])does not restart. - A clock-skew edge: if a persisted
deletionAtcomes from a Host clock that was ahead, it does not hide later deletions, because the Host itself pauses until real time passes its recordedobservedAt. - The new test passes locally 11/11, and CI is green.
Minor (P3):
- Polling cost on hosts where retention is off.
storage.retention.queryrunscountArchiveRetentionCandidates, aGROUP BYover all archived rows joined to the projection. The notices observer runs it every 60s for every ready Host while the window is focused, and also on each focus/blur. That includes Hosts where retention is disabled, which is the default and covers nearly everyone. Those Hosts can never produce a hold or pause, and produce alastDeletiononly if retention was on before. Options: back off whenenabled === false && !lastDeletion, dropblurfrom the triggers (blur cannot make a notice presentable), or add a preview-free query mode. - Settings acknowledgement leaves sticky warnings on screen.
acknowledgeRetentionResultswrites the seen-state, but a warning toast already shown withduration: 0stays until the Host clears the hold (up to a day) or the user closes it. That is a small UX inconsistency. - Unrelated refactors in
app-shell.tsx: theactiveCatalogSessiontoactiveSessionrename, the destructuring changes, and the inlinedparentId. They look like they exist to stay within thenonTriviaTokensbudget (7333 to 7330). They are behaviour-neutral but enlarge the diff. Consider splitting them out or noting them in the description.
CI / merge: all checks pass. The PR is MERGEABLE and BLOCKED on review. It is gated on #5902.
| visited.add(host.hostId); | ||
| let retention: StorageRetentionQueryResult; | ||
| try { | ||
| retention = await services.loadRetention(host); |
There was a problem hiding this comment.
P3: this calls storage.retention.query, which runs countArchiveRetentionCandidates (a GROUP BY over all archived rows). It runs every 60s for each ready Host while the window is focused, and also on every focus/blur, including Hosts where retention is off, which is the default. Consider backing off when !retention.enabled && !retention.lastDeletion, or using a preview-free query for the observer.
| const unsubscribe = bridge.runtimeHostProfiles.subscribeChanges(handler); | ||
| document.addEventListener('visibilitychange', handler); | ||
| window.addEventListener('focus', handler); | ||
| window.addEventListener('blur', handler); |
There was a problem hiding this comment.
P3: blur makes isVisible() false, so the refresh it triggers does nothing except bump version and abort any in-flight pass. focus and visibilitychange are enough.
| ...(deletionAt >= 0 ? { deletionAt } : {}), | ||
| ...(warning ? { warning } : {}), | ||
| }; | ||
| if (next.deletionAt !== previous.deletionAt || next.warning !== previous.warning) notices.writeSeen(host.hostId, next); |
There was a problem hiding this comment.
P3: Settings acknowledges the warning here, but a hold or pause toast that is already on screen (duration: 0) stays until the Host clears it or the user closes it. Consider letting the observer dismiss an active warning whose key has been acknowledged.
Archived-task cleanup results and clock pauses are currently visible only in Storage settings. This follow-up shows a Host-scoped notice in the application shell, with an action that opens that Host's Archived tasks settings.
The observer reads only already-connected Hosts while the window is visible and focused. It polls at most once a minute, persists the last announced result per Host on this Desktop, and treats results viewed in Settings as read. Successive cleanup batches are coalesced with a 15-minute reminder cooldown; clock warnings bypass that cooldown and are dismissed when the Host clears them. Hidden windows and stale responses do not acknowledge results. Same-name Hosts retain separate notices.
Depends on #5902. This PR currently includes its commits because the base PR has not merged. The notification change is the single commit
c682972c7; review the incremental diff. I will rebase onto main after #5902 merges. It adds no Host protocol operation or policy change.Part of #5776 / #5899. Idle archiving remains tracked separately in #5919.
Validation: Desktop build and all four typechecks pass; 19 focused tests pass, covering persistence, cooldown, clock warnings, Settings acknowledgments, visibility, disconnection, stale responses, disposal, and real toast navigation in all three locales for same-name Hosts. Format, lint, Knip (Desktop), ASF headers, locale hygiene, app-shell hooks, generated Astryx inventory, and strict renderer architecture checks pass. Hosted audit, Windows recovery, release packaging and installed CLI validation pass. The main
testcheck is still running.Limits: the notices are in-app rather than native OS notifications. The Host reports only its latest deletion batch, so the notice states the latest cleanup result rather than claiming a total across missed batches.