Conversation
Astro-Han
left a comment
There was a problem hiding this comment.
Independent review at b237ed1.
What changed: This PR adds an opt-in, per-Host retention policy that deletes archived tasks after 30/60/90 days. It adds storage.retention.query/set (epoch 202 -> 203) and a State Root document archive-retention.json. It also adds a maintenance lane: 8 families/tick, 1s active, 15 min idle. Each family is deleted through session.remove's path with a guard that runs under the removal admission. A Settings > Archived tasks section is added.
Scope checked, found sound:
- Opt-in: an absent or invalid document is treated as disabled. There is no SQLite migration (the schema-41
archived_atis reused). - Retention clock: it starts at
max(archivedAt ?? enabledAt, enabledAt), so pre-schema-41 rows count from enablement. Any enable or period change restampsenabledAtto at least the newest recorded time. - Restore race: the guard runs inside
#withStableRemovalPlan->#admission.runMany, the same gate unarchive takes. It rechecks archive state, pin state, setting revision andarchived_at. The commit is CAS-versioned. archived_atmaintenance: unarchive clears it, re-archive restamps it, and the archive-on-remove branch stamps it.- Excluded from deletion: pinned families, Agent Graph operators, and plans that would archive active subtasks or reclaim worktrees (held as
needs_review). - Clock going backwards: the sweep pauses.
- Setting changes: a change waits for the in-flight tick, and the guard holds while a change is pending.
- Merges cleanly into main. There were no prior reviews.
Tests: runtime-host retention/retirement/maintenance/protocol tests pass (72/72), as do the storage archive-retention-store tests (5/5).
Findings: P3 epoch collision; P3 no guard against the wall clock jumping forward before an unattended, permanent deletion; P3 preview wording.
Not exercised:
- The desktop UI was not run.
- Not verified: legacy
importSessionrows that are archived but have NULLarchived_atwould count fromenabledAtand could become eligible right after import. Worth a look.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
|
Thanks @Astro-Han. All four points are addressed in P3: forward clock jump. Agreed, this was the one unattended path where a misread clock destroys data. Neither suggested guard fits a local Host, though:
What I did instead is a gap hold. When the wall clock has moved more than
The trade-off is that someone genuinely away for more than 7 days sees the banner and a one-day delay. A bad clock, on the other hand, can no longer delete a backlog in the minute it is noticed. Tests run on an injected clock and cover:
Each of these was mutation-checked. Your import note was a real gap, and it's fixed.
P3: preview wording. You're right: the count includes families the sweep will hold, and it also misses subtasks orphaned later, so it is neither a lower nor an upper bound. The copy is now neutral, "N archived tasks are subject to automatic cleanup", in the section and in the confirm, in all three locales. P3: epoch. Checks on |
Astro-Han
left a comment
There was a problem hiding this comment.
Follow-up review at fefa3dc (delta since b237ed1: one commit, "hold retention after a forward clock jump").
Earlier points:
- Forward clock jump: addressed by the gap hold. The in-run detection, re-arming, the setting-change clear and the post-deadline restart check (persisted floor plus the newest metadata time) look correct. There is one regression in the restart path before the deadline; see the inline comment.
- Import: fixed.
mergeAttachedBundlerestampsarchived_aton archived bundle rows inside the import transaction, and there is a test. ExternalimportSessionalways writes non-archived headers, so it is unaffected. - Preview wording: fixed in all three locales and in the confirm text.
- Epoch: unchanged.
mainis still at 202, and this PR claims 203. #5753 and #5495 also claim 203 (#5826 now claims 204). The re-pin plan in the thread is fine; I am just flagging that the collision is still open.
Non-blocking design note: the hold defers deletions by only 24h. For a clock that really is wrong, the safety net is that the user opens Settings > Storage within a day. A clock that is wrong and unnoticed for more than a day still deletes the backlog. This is an acceptable trade-off for a local Host, but consider making the notice visible outside the Storage settings page (for example, a one-time notification) if that is cheap.
Tests: the runtime-host retention coordinator, retention protocol and retirement tests pass (68/68), including a temporary probe that I later reverted. The storage archive-retention-store and session-bundle-policy tests pass (8/8).
CI: test and windows_recovery pass. audit and Build immutable tarball fail on dependency advisories in shipped dependencies (axios, fast-uri). This PR changes no lockfile, so those failures are unrelated. The PR merges cleanly.
Verdict: one P2 (a false clock-jump hold, with a banner that stays up, after any restart before the deadline) and one P3 (epoch coordination). No data-loss paths found in the delta.
Automated review by Claude (Anthropic), posted on behalf of the maintainer. It is not an independent human review.
fefa3dc to
5000b16
Compare
|
Thanks @Astro-Han, good catch: your repro reproduces exactly. The fix is in P2: false hold on a fresh process before the deadline.
Notice outside Settings. Agreed that this would make the 24 h window more useful. It is the optional follow-up already listed in #5899 (a one-time notice for new automatic deletions), and I'll fold the hold into that follow-up rather than widen this PR. Epoch. Checks after the rebase:
|
|
One more fix, found while designing the next step (idle archiving): What was wrong. The pinned-family exclusion was a correlated What changed. The exclusion is now an uncorrelated Synthetic timings, same result sets:
Tests: storage retention 13/13, the candidate/page parity test, and the runtime-host retention coordinator 26/26. Biome is clean. The "Candidate rows" paragraph in the description still holds. Only its index claim was wrong. |
Astro-Han
left a comment
There was a problem hiding this comment.
Re-review at 5000b16 (rebased onto main 0b25078; the only new PR commit is 5000b16).
Prior findings
- P2, a false clock-jump hold when a fresh process restarts before the deadline: fixed. A fresh process now measures the gap from
max(persisted floor, newest metadata time)(archive-retention-coordinator.ts:197-200). A hold is cleared oncenow >= until, whatever the deadline (L188-192).storage.retention.queryno longer reports a hold that has expired (L417-420). The new tests reproduce my earlier probe exactly (a restart on day 9 with metadata written a minute earlier records no hold), and they also check that a real jump still holds and that an expired hold is cleared with a single write. Before the deadline the candidate list is still never read. - P3, epoch 203: still open, coordination only.
mainis at 202. #5709, #5495 and #5753 also claim 203, and #5826 claims 204. Whichever PR lands second needs to re-pin, as the author has already said.
Remaining (P3): see the inline comment. A fresh process can only tell when the Host last ran from Session metadata writes. A Host that kept running with no task activity for more than 7 days and is then restarted still shows the "system clock moved ahead" banner. It now lasts 24h instead of weeks, so this is cosmetic.
Verification: I built core, storage, mcp, runtime and runtime-host. archive-retention-coordinator, storage-retention-protocol and the session-retirement* tests pass 76/76. I also ran a temporary probe test, since reverted, for the idle-restart case below.
CI: audit and Build immutable tarball now pass because the rebase picked up #5906. windows_recovery passes. test was still pending when I reviewed. Mergeable.
Update for head fd9c3f36 (pushed after this review was drafted at 5000b160): the only new commit rewrites the pinned-family exclusion in archiveRetentionCandidatePredicate from a correlated NOT EXISTS to an uncorrelated NOT IN. Both sides are COALESCE(revision_root_session_id, session_id), so no NULL can reach the NOT IN list and the semantics are unchanged; the findings above still apply at this head.
This is an automated review by Claude (Anthropic), run on behalf of the maintainer.
fd9c3f3 to
ffaa68e
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Re-review at ffaa68e7. The branch was rebased onto main c7fa6bb6. git range-diff shows the five earlier PR commits unchanged (=), so the only new code is ffaa68e7 "persist idle retention heartbeat".
Prior findings
- P3, a Host that stays up with no task activity for more than 7 days and then restarts shows a false "clock moved ahead" hold: fixed. Sweeps now persist
latest.observedAtat most once a day (#heartbeatDue/#heartbeat,archive-retention-coordinator.ts:336-353). The heartbeat is also carried on#record,#pauseand#clearHold, and#loadfolds it into the floor (L579-584). It is written only after the gap check passes (L202-207), so a real forward jump is never laundered into the floor. The storage decoder accepts the new key and rejects non-integers, andstorage.retention.querydoes not expose it. I checked this with temporary probe tests, since reverted: 22 days idle past the deadline with sweeps every 12h, then a restart a minute later, records no hold; 9 days idle before the deadline, then a restart, records no hold (this was my earlier repro); and a real 8-day jump after heartbeats still holds, withsinceset to the last heartbeat. - P3, compatibility epoch 203: still open, coordination only.
mainis still at 202, and #5866, #5709, #5495 and #5753 are all still open and also claim 203.
New: P2, the test job will fail. See the inline comment. Because of the heartbeat, #finishPass now writes an unchanged pass once a day, so lastSweep is no longer undefined after a pass that deleted nothing. session-retirement-coordinator.test.ts:1561 still asserts that it is undefined, and it fails locally, deterministically, at this head. The other retirement and retention tests pass.
Verification: I built core, storage, mcp, runtime, acp, antigravity and runtime-host (tsc OK). Results: archive-retention-coordinator, storage-retention-protocol, session-retirement* and storage-maintenance pass 81/82 (the one failure is the test above); storage archive-retention-store and session-bundle-policy pass 8/8.
CI: audit, Build immutable tarball, the direct-peer addons, the installed-CLI validations and windows_recovery pass. test is still pending, and I expect it to fail on the test above. Mergeable; blocked only on review.
This is an automated review by Claude (Anthropic), run on behalf of the maintainer.
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>
bf8092d to
717841a
Compare
|
Follow-up for current head
All enabled checks on this head pass, including the full Host tests, Desktop e2e, Storybook/geometry checks, audit, Windows recovery, and installed CLI validation on Linux, macOS and Windows. Main CI · CLI validation. The code findings are addressed; this is ready for an independent human review. The notice outside Storage settings remains a separate follow-up from #5776 / #5899, covering both automatic cleanup results and clock pauses. |
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.
Re-review at exact head 717841a0. The previous review was at ffaa68e7. The branch was rebased onto main 7c90bac2 and is now MERGEABLE.
What changed. I compared the PR diff against its merge-base, old head versus new head. The only differences are:
- Conflict resolution in
packages/runtime-host/src/protocol/index.ts: the epoch went from 203 to 205, and main's 204 history line is kept. Indocs/astryx-surface-file-inventory.md, only the totals changed.git range-diffshows commits 2-6 unchanged, apart from the author on0f4d7a40. Main also touched the preload,execution-composition.tsandexecution-stores.ts, but the PR's hunks there are byte-identical. - New commit
717841a0"preserve cleanup results on heartbeat-only sweeps".
Earlier findings:
- P2 (CI red):
session-retirement-coordinator.test.ts:1561assertedlastSweep === undefinedafter a heartbeat-only sweep. This is fixed. When a pass is unchanged and a heartbeat is due,#finishPassnow calls#heartbeat(now), which writes onlylatest.observedAt(archive-retention-coordinator.ts:390-394).lastSweep.atis no longer rewritten. The new assertions inarchive-retention-coordinator.test.tscheck this: the heartbeat is written,lastSweepandlastDeletionstay undefined, an immediate repeat writes nothing, and an unchanged result keeps its timestamp. Locally, runtime-host retention, retirement and protocol tests pass 162/162, and storagearchive-retention-storeplussession-bundle-policypass 8/8. CItestis green. - Idle-restart false hold: still fixed. The heartbeat commit is unchanged by the rebase.
- P3 compatibility epoch: still open, with a new number. Main is at 204. This PR takes 205, and #5709 also claims 205. #4138 now claims 206, and #5495, #5753 and #5866 still sit at 203 on older bases. Whichever of #5902 and #5709 lands second has to bump. The fix is trivial, but please coordinate before merge.
Safety re-check (no change since the last review). Retention is off by default: DISABLED has enabled: false, and a document that fails to decode reads as disabled. The removal guard runs under admission. It keeps any family that has a non-archived or flagged member, or whose most recently archived member is not older than days. The age is measured from max(archivedAt, enabledAt), so no backlog is deleted on enable. It also keeps families with worktrees or archive bundles (needs_review). A forward clock jump causes a one-day hold, and a backward clock causes a pause. Each family is removed through the existing retirement path, so crash safety matches manual removal. The new commit touches only result bookkeeping, not eligibility.
CI / merge: all checks pass (test, audit, CLI validation, addon builds, windows_recovery). The PR is MERGEABLE. It is BLOCKED only on review.
No new code findings. The one remaining item is the epoch-205 coordination with #5709.
| // Increment when the same protocol version no longer guarantees safe Client-Host | ||
| // interoperability. Mismatches are rejected before domain commands are admitted. | ||
| export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 204 as const; | ||
| export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 205 as const; |
Closes #5899 (step 2 of #5776). It adds an opt-in, per-Host setting, delete archived tasks after N days, which is off by default. The Host decides and executes; Desktop exposes the setting and shows what happened.
Rules (as specified in #5899)
max(archivedAt ?? enabledAt, enabledAt), and the boundary is strict. Enabling the setting never deletes a backlog at once, and legacy tasks withoutarchivedAtcount from enablement.enabledAton the Host clock, so it is never earlier than the latest time the Host has observed. Disabling clears it.archivedTaskRowsfor a mixed fixture: revisions, live-parent subtasks, orphans, a graph operator, preparing rows, and rows with a missing projection.session.remove, together with every existing busy guard. Manual removal behaves exactly as before.How it works
Setting document.
archive-retention.jsonin the State Root holds{ version, revision, enabled, days, enabledAt?, latest? }.storage.retention.set. It is not part of the runtime policy, agent settings patches, or config export/import.Sweep lane in
HostStorageMaintenance. It starts after Ready.enabledAt + days), it reads no candidates. A fresh process reads the newest Session metadata time once to detect forward clock gaps.setreturns only once nothing more can be deleted.Results are latest-only:
lastSweep { at, deleted, skippedBusy, needsReview, failed, paused? }lastDeletion { at, count, bytes? }, wherebytesis measured before deletion with the same helper as the batch preview.Sweeps write results only when they change. A coarse Host heartbeat (
latest.observedAt) is persisted at most once a day, including before the first deadline, so an idle restart does not look like a clock jump. Heartbeat-only writes preservelastSweepandlastDeletionand are not exposed through the protocol. Logs carry counts only.Protocol (epoch 204 → 205)
storage.retention.query {}returns{ revision, enabled, days, enabledAt?, preview: { count, eligibleAt? }, lastSweep?, lastDeletion?, hold? }.storage.retention.set { expectedRevision, enabled, days }returnscommittedorrevision_conflict.session.remove.Desktop: Settings › Archived tasks
mixedfor that reason.Verification
dist, are all caught.build:test, Biome,format:check,lint, Knip (desktop and ui),protocol-epoch-check(204 → 205),check-renderer-architecture --strict-base, locale hygiene, the Astryx inventory, the Windows inventory, app-shell hooks, ASF headers,git diff --check, and the desktop typecheck.main.dev:worktreeon a disposable copy of a real workspace.archive-retention.jsonrecordedenabledAt.enabledAtback 31 days and the archive times of three tasks back 40 days in the copy, then restarted.Size: production code is +2171/−28, of which about 145 lines are copy and about 57 are the test-only memory store. Tests are +1868.
AI use
Implemented and reviewed with Claude Code; the commits carry a
Co-Authored-Bytrailer.🤖 Generated with Claude Code