Skip to content

feat(system): report progress while the session Trash empties - #5195

Merged
bolichen97 merged 1 commit into
mainfrom
feat/trash-empty-progress
Aug 24, 2026
Merged

feat(system): report progress while the session Trash empties#5195
bolichen97 merged 1 commit into
mainfrom
feat/trash-empty-progress

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

1. What is the problem?

Emptying the session Trash said nothing while it ran and nothing when it finished.

POST /api/system/session-storage/empty held the request open until every staged file was gone. On a real store that is one batch of 55,323 sessions and 18GB, which is minutes of filesystem work, and for all of it the screen only greyed out Restore / Cancel / Delete forever. Reported verbatim: "I don't even know whether it deleted - all I know is that when I came back 10 seconds later it was gone."

Three defects sat behind the same click:

  • Leaving the page looked unsafe, and coming back lied. The delete does survive navigation (worker thread, and aiohttp does not cancel handlers on client disconnect), but nothing said so. Worse: the shared QueryClient sets staleTime: Infinity and this screen's inventory query did not refetch on mount, so returning mid-delete re-rendered the CACHED pre-delete list - batch still there, totals unchanged. That reads as "it did not work".
  • A refusal was silent. _empty_trash_locked correctly KEEPS a batch holding files no manifest lists, because those are the only copy - and it does not raise, it logs and moves on. The endpoint reported success regardless, the batch simply stayed, and no reason was given.
  • Progress was not implementable as the code stood. The delete called shutil.rmtree once per batch, so the only granularity available was "a batch finished" - on a single 55,323-session batch, one step from nothing to done.

2. Why this issue matters to the user

This is the only irreversible action in the storage surface and the only one that returns disk space. "Did the thing I cannot undo actually happen" is the question the screen exists to answer, and it was the one question it could not.

3. How our fix solves it

Chain from the symptom down:

  1. Per-batch rmtree is the wrong granularity for a big batch, but fixing that properly means replacing the removal itself, which is a security change and is now split out (see below). What this PR does instead is make the one report per batch HONEST. _delete_listed_files measures the bytes from the manifest, calls shutil.rmtree, then measures again and subtracts what survived - ignore_errors=True returns quietly when a locked file leaves the batch standing, so the up-front figure would otherwise be reported as reclaimed with the bytes still on disk. It then checks as a POST-CONDITION that the batch is actually gone, and reports it as kept if not. The byte sums come from the manifest rather than a directory walk, which is also the safer way to get them: on Windows a junction is not a symlink (os.path.islink reports False for one), so os.walk descends into it and would measure files outside the trash entirely. Callers reach that function only after _unlisted_files has confirmed the batch holds nothing the manifest omits, so the manifest describes the whole batch.

  2. The request could not report anything because it only returned when the work was over. It now answers 202 with a job, and GET on the same path returns that job's counters. The read touches no store - every other endpoint in this module walks the sessions on disk, which is why none of them can be polled and this one can. A finished job stops being reported after 10 minutes, so an outcome is never presented as current days later.

  3. Leaving was already safe but unstated. The job is deliberately not tied to the request, and the status read is what lets the screen pick up a run started before it mounted. The screen says so in words.

  4. A refusal had nowhere to land once the request was answered. empty_trash gained an on_skip callback carrying a reason CODE per kept batch; the job records them, audits as refused, and the screen states them in the user's language - a code rather than the gateway's own sentence precisely so it can be translated. A crash lands the same way as a generic line with the detail in the log; the broad except is deliberate, since an unhandled error would otherwise leave the job flagged running for the life of the process.

  5. The stale list is fixed by staleTime: 0 on the inventory query, plus one re-read when a job settles, keyed by job id so two empties each get their own refresh and a remount does not re-fire for one already accounted for.

  6. What "empty everything" MEANS changed, and a reviewer should know it. The POST now resolves, under the storage mutation lock, which batches it will destroy and hands the worker that explicit list, so the request's own moment is the consent boundary and a batch staged afterwards survives. empty_trash(None) enumerated the trash inside the worker, which deferring the work moved from milliseconds after the click to minutes - and a staged batch is the only copy of those sessions. If that read fails for all: true the job settles with a reason and deletes nothing; an explicit selection proceeds, because the caller already named the set.

Progress is measured in bytes against the staged total, not in sessions: the delete walks files, a session is more than one file, and a session-shaped count would be a guess. The denominator comes from the staged manifests - the same figure the trash row already showed.

A second empty is refused with 409 rather than queued, and the slot is claimed in the SAME synchronous step as the check: reading the staged totals first put a suspension point between guard and claim, so two near-simultaneous POSTs both passed and the second overwrote the first. The job slot is process-local and not persisted: if the gateway dies mid-delete the files are gone either way.

Two files this PR already owns became black-clean and were pruned from .github/black-baseline.txt (2 deletions); the formatting churn inside them is that graduation, not a change of behaviour.

What this PR deliberately does NOT do

An earlier revision of this PR also replaced shutil.rmtree with a descriptor walk -
every ancestor opened O_NOFOLLOW, files named by the manifest and removed by
(directory fd, name), directories removed bottom-up by descriptor - which closes a
TOCTOU window that deferring the delete into a background job widens from milliseconds
to minutes, and which would also allow per-FILE progress instead of per-batch.

That work is now split into its own change, for reasons three reviewers reached
independently: it is a bespoke security-critical surface arriving inside a UX fix, it is
POSIX-only (Windows has neither openat nor O_NOFOLLOW, so the hardened path would not
exist exactly where junction semantics differ), and most of this PR's review rounds were
spent inside it. It deserves its own review on its own merits rather than being carried by
a progress bar.

So the delete here is the stdlib rmtree this repo already shipped, unchanged in
mechanism. Everything this PR adds is reporting: a job, progress, measured byte figures,
and refusals the user is actually told about.

  1. Two smaller states the screen was missing. A running note sits with the CONTROLS, not just in the Trash section: the run greys Move to Trash, Restore and Preview sweep for its whole duration, and on a large store that is minutes of dead buttons whose only explanation was offscreen. And empty_not_started covers a POST that never became a job - onSettled disarms the confirm on any outcome, so a failed start cleared the button and left nothing behind, which is this PR own reported symptom in miniature.

Screenshots

Captured by website/scripts/capture-trash-empty-progress.mjs against fixtures modelled on the reported store (one policy batch, 55,323 sessions, 18GB).

Mid-run - partial figure, bar, and the line that makes leaving safe to do:

Emptying, mid-run

Finished - what it freed, with the batch gone from the list:

Emptying, finished

A batch KEPT - the refusal that raises nothing and used to render as "Freed 0 B." above a batch that was still there:

Emptying, a batch kept

4. What tests we did

  • test/test_session_storage.py (98 in file, green): progress is a monotonic running total across batches whose last value equals the return; a kept batch reports SKIP_UNLISTED_FILES and frees nothing; a linked directory inside a batch is not traversed and its target survives; a manifest rel pointing at another batch is refused. The 12 pre-existing empty_trash safety tests pass unchanged, which is the evidence containment and the unlisted-file refusal did not move.
  • test/test_session_storage_api.py (43 in file, green): 202-then-freed with the audit; a partial figure read MID-RUN while the delete is held open; two SIMULTANEOUS POSTs producing exactly one job (202 + 409); a kept batch audited as refused with skipped on the job; a refusal and an unexpected error each finishing the job instead of hanging it; a stale finished job no longer reported.
  • test/test_error_code_contract.py green: the 409 body carries a machine-readable code.
  • website/src/test/SessionStorageScreen.test.tsx (36 in file, green): running figure, the "keeps running" line, picking up a job started before mount, the finished total, the kept-batch refusal (and a repeated reason stated once), the partial-freed failure copy, other actions held, and the confirm disarming on acceptance.
  • flake8, isort, mypy (1031 files) and the black gate all clean; coverage 89% / 91% on the two changed modules from these test modules alone (80% floor). Frontend typecheck, lint (0 errors), i18n:check all green with the pseudolocale regenerated.
  • Rebased onto main after fix(i18n): boot the thinking-block capture through the all-languages entry #5166 landed, so the i18nAllLanguagesEntry red is gone. Backend Tests (shard 2) on test_driver_session_directives.py is inherited - it fails identically on fix(dashboard): add meta.mid tier to save-side foreign fold (#5152) #5196, whose diff is unrelated to both.

5. Any other suggestions on the work

  • Restoring a batch and moving 55,000 sessions INTO the trash are the same shape of long operation with the same silence. They can reuse this job pattern; deliberately not widened here.
  • The status read is a poll because that is the cheap correct thing for one screen. If more surfaces need it, the settled event belongs on the existing WebSocket invalidation path instead.

Closes #5194

@chenmingwei23
chenmingwei23 requested a review from a team August 23, 2026 06:16
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 23, 2026 06:16
@chenmingwei23
chenmingwei23 requested a review from dwu96 August 23, 2026 06:16
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 9ed7ee424a84c58530cf6936b1029aba3ce7907d — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Job-based deferral with a locked consent-time snapshot, honest skip codes, and the security-critical descriptor walk correctly split out — sound, proportionate shape.

[DESIGN-REVIEWED] 9ed7ee4

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ human override accepted

Human judgment by @chenmingwei23 overrides the GPT 5.6 finding for 9ed7ee424a84c58530cf6936b1029aba3ce7907d; the recorded reason is authoritative for this commit.

This comment is updated in place on each push.

The model was not re-run because an authorized human decision supersedes it.

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 9ed7ee424a84c58530cf6936b1029aba3ce7907d: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 9ed7ee424a84c58530cf6936b1029aba3ce7907d — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

First-Principles-Verdict: PASS

A reported defect ("I can't tell whether the irreversible delete happened") traced through every item; the two deeper causes are named, split out, and recorded in the spec.

What this change ships

Intent: let a user see that emptying the session Trash is running, what it freed, and why anything was kept — a FIX for a verbatim-quoted report, plus the surface needed to say it.

  1. Empty answers at once; the delete runs on in the gateway (202 + job) — justified
  2. Live byte progress against the staged total in the Trash section — justified
  3. A kept batch states its reason in the user's language instead of "0 bytes freed" — justified
  4. Returning mid-delete shows the run; the list refreshes once per finished job — justified
  5. A second empty gets 409 carrying the running job — justified
  6. "Empty everything" now destroys only batches staged at click time — declared semantic change; derived (a later-staged batch is the only copy)
  7. A stale or malformed batch id is refused 400 instead of a silent zero-byte success — justified
  8. Freed bytes measured from the manifest after the attempt; a surviving batch reported kept — symptom-level by design; the cause (descriptor-walk removal) is declared deferred and recorded in session-storage.md
  9. Manifest names validated once in _plain_parts (absolute/drive/NUL) — justified by the documented tampered-manifest invariant
  10. Busy note at the dead controls, "couldn't start" line, capture script + screenshots, 2 black-baseline graduations — declared riders; the script/screenshot pattern matches ~225 existing capture-*.mjs files

Checks run: no existing job/status mechanism to reuse (each backgrounded handler in dashboard/handlers is ad hoc — grepped asyncio.create_task, 80+ sites, no shared job shape). Every SKIP_* code and job field has a real frontend consumer in EmptyProgress/skipReason; job.task is retained to keep the task referenced, which is derived. The one symptom-level item (8) states its level and what is left, in the same commit's spec — accepted-and-deferred, not relitigated.

[FIRST-PRINCIPLES-REVIEWED] 9ed7ee4

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

UX-level review of 9ed7ee424a84c58530cf6936b1029aba3ce7907d — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: CONCERNS

The refusal path says "this batch" but never says which — with several batches listed, the one message users must act on points at nothing.

Watch

  • Kept-batch refusal is not tied to a batch. job.skipped carries only reason codes, so kept_unlisted_files ("A batch holds a staged file…") and kept_next_step ("Restore what you can from this batch") render once at section level above three identical-looking rows (kept.png). The user is told to restore "this batch" with no way to tell which of the three it is — on the only irreversible surface, the one actionable instruction has an unresolvable antecedent. Rare path × real friction (wrong-batch restore or a log dive) × every refusal. Smallest fix: carry the batch id in skipped and badge the kept row, or name the batch (its "N sessions · size" summary) in the sentence.
  • Deduplication understates plural refusals. [...new Set(job.skipped)] collapses two batches kept for the same reason into one "A batch…" sentence, so kept.png shows "Freed 0B" with three surviving batches under copy claiming a single batch. Same root cause; fixing the batch-attribution above resolves it.

Suggestions

  • startFailed (emptyMut.isError && emptyJob === null) is true in the gap between a 409 and the next poll, flashing "Couldn't start emptying the Trash. Try again." before the live progress replaces it — gate it off while the job query is fetching.

[UX-REVIEWED] 9ed7ee4

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 9ed7ee424a84c58530cf6936b1029aba3ce7907d — this comment is updated in place on each push.

Review details

Both candidates fail the falsification bar, and I found no grounded defect to add.

Candidate 1 (missing SEL audit on the fail-closed all:true settle path): reachability (a) is unproven — a non-SessionStorageError escaping staged_targets(None) is a defensive catch-all with no demonstrated real trigger (the candidate itself concedes it "could not fully enumerate" one). The outcome is also benign: the branch deletes nothing, and the pre-PR code's own refusal path (SessionStorageError → _refused) likewise emitted no SEL event, so this is consistent with the existing convention that refusals aren't audited while completed deletes are (_run_empty_job always logs). No data loss, no crash. Drops below 80.

Candidate 2 (unsynchronized job.skipped.append racing JSON serialization): self-rated low, and (c) is explicitly "no crash under the C json encoder… at most a stale/early value." A momentarily stale progress-poll value is not an observable wrong outcome for a pollable status endpoint. Drops well below 80.

Step 2 turned up nothing new: the fail-closed path sets done=True and leaves task=None, so it does not wedge the slot (a fresh POST is admitted, and this is tested); the check-and-claim is synchronous with no await between guard and claim; and _delete_listed_files performs the same shutil.rmtree(batch) as before, changing only the byte measurement (now manifest-based, gated behind the unlisted-files guard), not what gets deleted. The _plain_parts validator and the explicit-selection fallback are heavily and correctly tested.

No findings.

[OPUS-REVIEWED] 9ed7ee4

Verdict parsed from the review's SHA-scoped output markers for commit 9ed7ee424a84c58530cf6936b1029aba3ce7907d.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 9ed7ee424a84c58530cf6936b1029aba3ce7907d: <one-sentence reason>

@chenmingwei23
chenmingwei23 force-pushed the feat/trash-empty-progress branch from fc2b427 to 772e0ed Compare August 23, 2026 06:51
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 23, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

All findings from GPT 5.6, Opus 4.8, First Principles and UX are addressed at 772e0ed. Disposition, one by one:

GPT BLOCKING - Windows junction traversal. Real, and fixed by removing the traversal entirely rather than hardening it. _delete_listed_files now deletes the files the MANIFEST names; nothing is discovered by walking, so there is no descend-or-not decision for a junction to win. Each named path's PARENT is resolved and confirmed inside the batch before the file is touched, which also closes a .. in a tampered manifest (new test: test_a_manifest_rel_that_escapes_the_batch_is_refused). Directory removal is still shutil.rmtree, so its own junction handling is unchanged from main. A linked directory inside a batch is now covered by test_deletes_only_what_the_manifest_names, which asserts the link's target survives.

GPT / UX - a kept batch reported success. Real. _empty_trash_locked keeps such a batch and does not raise, so reading the outcome from the exception alone left the job clean and the screen rendering "Freed 0 B." above a batch that was still listed. empty_trash gained an on_skip callback carrying a reason CODE per kept batch; the job records them, the audit outcome becomes refused, and the screen states the reason. The screenshot for that state is now a real render of the reachable path, not a patched-to-raise fixture.

GPT / Opus - the slot claim raced the totals read. Real. _empty_job is now assigned in the same synchronous step as the guard, before any await, and the totals are filled in after. Regression test: test_two_simultaneous_posts_produce_one_job fires two POSTs through asyncio.gather behind a slowed totals read and asserts exactly one 202 and one 409.

UX - the last-finished job rendered forever. Real. A settled job stops being reported 10 minutes after it finishes (_JOB_TTL_SECONDS), decided server-side where the clock is, so the client needs no timestamp and cannot drift. Test: test_a_stale_finished_job_stops_being_reported.

UX - the failure copy dropped what it had freed. Fixed: empty_failed is now "Stopped after freeing {{freed}}: {{reason}}".

UX - {{reason}} was backend prose. Fixed for the skip path, which is the reachable one: the gateway sends unlisted_files / unreadable_batch / outside_trash_root and the screen maps each to a translated sentence in all 12 locales. The error string for a raised SessionStorageError or an unexpected stop is still gateway prose - that path is genuinely exceptional and the alternative is inventing codes for errors nobody has seen.

First Principles - fields with no consumers. Agreed and applied: total_sessions, started_at and finished_at are gone from the payload, and _staged_totals shrank to _staged_bytes. The staleness rule those timestamps would have served is applied on the server instead.

One consequence worth flagging: the 409 body now nests the job under job instead of spreading it, because the spread hid code from the error-code contract scan.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/trash-empty-progress branch from 772e0ed to 0e44e66 Compare August 23, 2026 07:09
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 23, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 2 findings addressed at 0e44e66.

GPT BLOCKING - per-file deletion has a symlink-swap race. Real, and the diagnosis is right: validating a path and then unlinking it resolves the prefix twice, so a component swapped to a link in between is followed. The suggested fix was to revert to batch-level rmtree, which would delete the feature - so the race is closed instead of traded away. Every file is now removed by (directory fd, name), with each directory component opened O_NOFOLLOW relative to the previous one from the batch downward. Nothing resolves a path at unlink time, so there is no window to win, and a swapped component fails the open rather than being followed. Descriptors are cached per directory, so a 55,000-session batch opens cli/ once. A .., absolute or empty component in a tampered manifest is refused outright.

Two tests, one for the mechanism and one for the outcome: test_every_staged_unlink_is_addressed_by_descriptor spies on os.unlink and asserts every call passes a dir_fd and a bare NAME - Path.unlink() passes a full path and no descriptor, so the previous version fails it - and test_a_swapped_directory_component_is_refused_not_followed pins that a staged directory replaced by a link to a live store leaves that store intact.

Windows has neither openat nor O_NOFOLLOW, so a per-file unlink cannot be made race-proof there. That platform keeps rmtree and progress degrades to one report per batch; the coarse path is guarded by _FD_SAFE_DELETE and the descriptor test skips rather than lying.

GPT BLOCKING - a denominator failure permanently wedges emptying. Real. The staged total is read through list_trash, which parses manifests and can refuse a malformed one; raising there after the slot was claimed 500'd the POST and left a job that never finishes, so every later attempt answered 409 for the life of the process - emptying became impossible. The read is now guarded: the total stays 0, the job starts, and the delete runs. Test: test_a_failed_denominator_read_still_starts_the_delete, which also asserts the slot is free for a second attempt afterwards.

First Principles - _EmptyJob.started_at is written and read by nothing. Correct, removed.

Also fixed, from the Korean and Hindi style gates rather than a reviewer: three ko strings put a single particle after an interpolation, where the form depends on a final consonant that does not exist until render (now written with both forms), and one hi string used the formal second-person pronoun against style/hi.md section 4.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/trash-empty-progress branch from 0e44e66 to 49a15e2 Compare August 23, 2026 07:28
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 23, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 3 findings addressed at 49a15e2.

GPT BLOCKING - the coarse path stats unvalidated manifest paths. Correct. The descriptor path validated each entry's components inline, and the size read on the rmtree path did not - so a tampered entry could make it measure, and report as freed, a file the delete never touched. The check is now one function, _plain_parts, that both callers go through, which is also the reason the gap existed: the rule was written at a call site instead of at the name. Test: test_the_size_read_refuses_a_rel_that_escapes_the_batch.

GPT BLOCKING - job status exposes unredacted content. Fixed: the refusal text now goes through the module's own _redact (the two scrubbers every other payload here uses) and is capped at 400 characters. To be exact about what that buys, since "unredacted" could be read as stronger than it is: the scrubbers match credential SHAPES, not arbitrary prose, so this removes key material and exfiltration URLs from a message that may quote the caller's argument - it is not a filter over every possible sentence. The test asserts the AWS documentation key shape does not survive, plus the cap.

GPT FINDING - polling stops at running: false, so the server's expiry is never observed. Correct, and the sharper version of the bug I had just fixed: the gateway retires a settled job on its own clock, but a client that stopped polling never sees that, so the outcome line sat on an open tab indefinitely - the same defect, moved from the gateway into one session. The poll now continues at 30s while a job exists and stops when GET returns null.

Three more garbled tokens in my translations. All mine, all real: ja emptying_leave_ok had a non-word where "page" belongs, ko emptying and kept_outside_root had a wrong middle syllable in "recycle bin", hi's four kept_* strings had a non-word where "batch" belongs, and bn emptying misspelled "is being". Corrected and read back out of the JSON. Root cause on my side: I was writing these as escape sequences, where a wrong code point looks like every other code point. They are literal text now, which is why the last round's typo class did not recur in the strings I had already fixed.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/trash-empty-progress branch from 49a15e2 to e24f5fc Compare August 23, 2026 07:42
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 23, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

First Principles items addressed at d69d8ff. GPT, Design, UX and Opus are clean/PASS on the descriptor-only head.

Subtraction: _staged_targets was a pure pass-through. Correct, deleted. The POST handler now calls session_storage.staged_targets directly and the rationale moved to the call site, where the reader of the guard is. Tests patch handler.staged_targets.

refetchOnMount: 'always' was redundant. Also correct and removed: with staleTime: 0 a query already refetches when it mounts, so the pair was two spellings of one rule. Behaviour unchanged, one option fewer.

The WebSocket broadcast, though: not in this PR. The observation is right - the dashboard's freshness mechanism is WS invalidation, and one broadcast when a storage mutation settles would let this query go back to being cache-backed and delete the per-job settled-refresh effect too. I have recorded that as the better long-term shape in the query's own comment rather than doing it here, for two reasons.

First, cost and timing: it adds a backend broadcast surface to a PR that has already taken eleven rounds of blocking review, and every recent round has been a case of me moving a mechanism and leaving the layer beneath it inconsistent. Adding a new notification path is exactly that shape again.

Second, and more substantively, an event and a mount-time read are not interchangeable for this defect. A missed event brings the wrong list straight back: a tab that stays open across a gateway restart, or a reconnect that drops the frame, has a warm cache and no invalidation, which is the reported bug verbatim. The mount-time read is the floor that holds when the event does not arrive; the event is the optimization that removes the scan in the common case. The right end state is both, with the read as the fallback - not the event instead of the read.

So the scan cost stands, bounded: one extra store walk per time the screen is opened, on a screen someone opens to decide what to delete.

@github-actions github-actions Bot removed the readiness: checking Automated validation is still running label Aug 23, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 12 finding addressed at aa7036f.

GPT BLOCKING - premature manifest deletion hides recoverable files. Correct, and caused by my own previous round: the descriptor sweep refuses to remove a regular file it does not recognise, the manifest is such a file, so I unlinked it BEFORE the sweep to keep the batch removable. That created the window GPT names - a listed file whose unlink failed leaves the batch on disk with no manifest, and list_trash() omits a batch without one, so the user has data they can neither see nor restore.

The manifest now goes LAST: its size is measured up front, the sweep is told to skip it by name (keep=MANIFEST_NAME) so it is neither deleted early nor counted as an unaccounted leftover, and it is unlinked only after the sweep reports the batch otherwise empty. If that unlink fails the batch is reported incomplete rather than silently kept.

test_a_surviving_file_keeps_the_manifest_so_the_batch_stays_restorable refuses the unlink of one listed file and asserts the manifest survives AND the batch is still in list_trash() - the consequence that matters, not just the file's presence.

Writing that test surfaced a second bug, mine and worse in kind: my first version called monkeypatch.undo() mid-test, and the stores fixture takes the SAME function-scoped monkeypatch, so undoing also reverted its KIROCREW_HOME / KIRO_HOME isolation. list_trash() then read the real data home instead of the temporary one - the assertion was measuring a live trash, which is how it failed with a batch id that belonged to neither the test nor the current clock. It uses a scoped pytest.MonkeyPatch.context() now, with the reason recorded inline so it does not come back. Read-only in that window; nothing was written outside the tmp tree.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 13 finding addressed at 3bb3564. This is the 7th finding inside the delete function, and the SECOND consecutive one about premature manifest deletion - same defect, different route.

GPT BLOCKING - a tampered manifest can delete its own recovery metadata. Correct. Last round I kept the manifest out of the SWEEP and removed it only after the sweep proved the batch empty. But an entry reading rel: "manifest.jsonl" passes the plain-name check, so the per-file unlink LOOP still removed it first - and with any listed file surviving, list_trash() then omits the batch and the data is neither visible nor restorable. The consequence is identical to the round-12 finding; only the code path differs.

The loop now refuses an entry that names the manifest, and the manifest is removed in exactly one place: after the sweep reports the batch otherwise empty.

test_a_manifest_that_lists_itself_cannot_strand_the_batch tampers the manifest to list itself AND refuses one real file's unlink, then asserts the manifest survives and the batch is still in list_trash().

Worth stating plainly rather than presenting this as a clean catch: two rounds in a row have been the same invariant ("the manifest is the batch's recoverability, so it dies last and only once") enforced at one site and missed at the adjacent one. The invariant is now stated in the code at both sites and in the module spec. If a third route into it appears I will stop and hand this function to a human reviewer rather than patch a third time.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

First Principles items addressed at 7b056a3. GPT, Design and UX are all clean/PASS on the previous head, and Opus reported no blocking findings.

Subtraction: refetchOnMount: 'always' on the session-empty-job query. Correct, removed - and a fair catch of an inconsistency I created: I took that option off the inventory query last round, wrote the comment explaining it was "two spellings of one rule", and left the same pair three lines below on its sibling. Both queries now carry staleTime: 0 only.

staleTime: 0 as a symptom patch. Agreed, and your own disposition matches mine: the cause-level fix is one storage-mutation push event, which would let all of these go back to being cache-backed. The sibling count is useful evidence I did not have - SkillsTab.tsx:94, SkillsTab.tsx:604, SkillContextBudget.tsx:53 are the same opt-out for the same reason, so this is a pre-existing pattern rather than something this PR introduces, and a fix that only converted this screen would leave three behind.

Staying deferred for the reason I gave earlier: an event and a mount-time read are not interchangeable for the reported defect. A missed event brings the stale list straight back - a tab open across a gateway restart, or a reconnect that drops the frame, has a warm cache and no invalidation, which is the bug verbatim. The right end state is the push event PLUS the read as the floor, and that is a change to the dashboard's freshness mechanism rather than to this screen, so it belongs in its own PR with those four call sites converted together.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

UX and First Principles items addressed at c263ccc. GPT, Design and Opus are clean on the previous head.

UX - a stale startFailed contradicts the job outcome. Real, mine, and the sharpest finding on this screen so far because the failure mode is the screen lying about the one thing it exists to report. react-query keeps isError set until the next mutate, so after a 409 (a second tab) the error stayed latched while the poll picked up the real job - and the moment that job settled the screen printed "the delete could not be started" directly above "Freed 18GB". Told the user the delete both never ran and freed 18GB.

Gated on the ABSENCE of a job rather than on the mutation state: emptyMut.isError && emptyJob === null. When a job exists the job is the answer, which is the rule the rest of this screen already follows. test_lets_a_picked_up_job_win_over_a_latched_start_error stages a settled job plus a failing POST and asserts the outcome shows and the start-failure line does not.

UX suggestion - "Nothing was removed." is false on the 409 path. Taken: another delete IS removing things there, it just was not started by this request. Reworded to "This request did not start a delete." in all 12 catalogs, which is true in both cases.

First Principles - staleTime: 0 is a symptom patch. Your disposition and mine agree, and the deferral is written where the next reader trips on it. Unchanged from my earlier answer: an event and a mount-time read are not interchangeable for this defect, so the end state is both, in a PR that converts all four opt-out sites together.

First Principles - _EmptyJob is the second 202-job/poll shape. Agreed on both halves: it survives as meaningfully different because the cloud sibling PERSISTS jobs and persistence is precisely the harm this slot names (a resurrected record claiming a delete is running), and a third instance should be extracted by deleting the copies rather than adding a third. Nothing to change here; recorded so the next one triggers it.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 14 finding addressed at f1e9b50. Not in the delete function - this one is my test fixture.

GPT BLOCKING - teardown leaves a filesystem worker running. Correct, and worse than the hang it replaced. My _no_job_leaks fixture called job.task.cancel(), which only abandons the asyncio.to_thread WRAPPER: the worker thread keeps running empty_trash and keeps deleting files, straight through tmp_path teardown. So a failed assertion turned into a real delete racing the fixture that was removing the same tree. I added that cancel two rounds ago to stop a hang, and traded a loud failure for a silent one.

Teardown now WAITS for a still-running job, bounded at 30s, and on timeout fails with the test's name rather than walking away from a live thread. A hang is a worse test experience than a cancel; a thread deleting files past teardown is a worse defect, and the bounded wait avoids both.

test_a_job_left_running_is_awaited_by_teardown_not_abandoned covers the guard itself by deliberately returning with a job in flight (a 0.2s worker) - without it the wait branch never executes and could rot unnoticed, which is how the cancel survived two rounds. To be exact about what that proves: it shows the wait branch RUNS and does not error in teardown (the event loop is still usable there). It does not independently prove the thread was joined; that follows from awaiting the task that owns the to_thread future.

Standing count for the escalation rule I agreed with the user: the delete function's consecutive streak is broken - rounds 13 and 14 were the frontend and this fixture.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 15 addressed at cb11c4c. Both items are real; neither is in the delete function.

BLOCKING - a Windows drive-relative name bypasses containment. Reachable, verified before fixing: _plain_parts("C:.ssh/id_rsa") returned ("C:.ssh", "id_rsa"). is_absolute() is False for a drive-relative path, and my component check for a trailing colon only ever saw a bare C:, so C:.ssh walked through. On Windows the join then replaces the anchor - pathlib lets a right-hand side carrying a drive take over - and resolves against that drive's working directory, so _listed_bytes() stats a file outside the batch and its size reaches the job response: an existence-and-size oracle for an arbitrary path. POSIX is unaffected (the same join stays inside the batch), and the delete itself never uses these names on Windows, which takes rmtree on the batch.

Fixed as you suggested - any PureWindowsPath(rel).drive is refused - which is strictly more general than what I had and also catches c:y, a second spelling I was missing.

One consequence worth stating rather than burying: .drive also refuses a POSIX file legitimately named a:b, because Windows parsing reads those two characters as a drive. My first version of the test asserted a:b/c was still accepted and failed, which is how I found it. Kept the broad rule and documented the trade: this store names its own files so it never writes one, and being wrong costs a batch reported incomplete and kept, not an escape. The test now pins both halves - a:b/c refused, ab:c/d accepted.

FINDING - a locked Windows file is reported as freed. Also correct. shutil.rmtree(batch, ignore_errors=True) returns quietly when a lock leaves the batch standing, and freed was the up-front _listed_bytes, so the user got "Freed 18GB" with the bytes still on disk - alongside the incomplete warning, contradicting it. That is this PR's own defect class in the one branch I cannot exercise locally. Now measured after the attempt and the survivors subtracted. test_the_coarse_path_reports_only_bytes_that_went_away forces the coarse path with a no-op rmtree and asserts freed is 0 with SKIP_INCOMPLETE.

Escalation count: the delete function's consecutive streak stays broken - rounds 13, 14 and 15 were the frontend, the test fixture, and the shared name validator plus the Windows branch.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Design and UX round addressed at b7d8302. GPT and Opus are clean, First Principles is PASS.

UX - busy greys the top controls for the whole run with the reason offscreen. Real, and it is this PR's own defect one level up: the screen knew something the user could not see. On the reported store the run takes minutes, the controls are at the top, and "Emptying Trash" was in the Trash section past a long list. A running note now sits with the controls, role="status", saying the Trash is emptying and some actions are paused until it finishes. I did NOT narrow the lock instead - the empty holds a storage mutation lock and loosening it to improve a message is the wrong trade.

UX - job.error is untranslated English as the only explanation. Real, and the worst place to have it: a delete that stopped partway through something irreversible. empty_failed_next_step now renders above the raw sentence in all 12 locales ("Try Delete forever again. Details are in the gateway log."), and the gateway's own words stay as the detail rather than the explanation.

UX - empty_not_started buries the verb. Taken: "Couldn't start emptying the Trash. Try again." Worth noting this is the second reword of this one string in two rounds - your earlier note got it off a false claim ("Nothing was removed."), this one gets it to lead with the action.

Design - the delete rewrite is never weighed in the description. That is the actionable half of the concern and it is fixed: the body now has a section stating the rmtree alternative, why it was replaced (progress made an existing TOCTOU window load-bearing - milliseconds to minutes), what the descriptor path buys, and what it costs (a bespoke security-critical surface, most of this PR's review rounds spent inside it, POSIX-only since Windows still takes rmtree). It ends by saying that preferring the small fix on rmtree with the TOCTOU handled separately is a coherent position and the PR should be split rather than argued down. The trade itself was consciously accepted by the repo owner earlier; it should not have been accepted only in a chat log.

Design - temp-screenshots/*.png is permanent in git history. Agreed on the substance - "temp" in the name, forever in the tree, first occurrence of the pattern, ~400KB across two PRs. Not changing it unilaterally: the committed-file-plus-raw-URL route exists because it is the only one gh can do without a browser upload, and the repo owner asked for screenshots in the body. Raised with him to decide between dropping the directory, keeping it, or moving to uploaded assets. Whatever he picks applies to #5191 too.

Design suggestion - extract the job/poll pair into _shared when a second user arrives. Agreed, and it matches First Principles' count of one existing sibling. Not doing it now.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 16, and the 8th finding inside the delete function. Answering this one rather than fixing it, with evidence, and flagging it for a human decision instead of a 9th patch.

BLOCKING - final-component races bypass the unlisted-file guard. The race is real and I am not disputing it. Between the check that accepts an entry and os.unlink(name, dir_fd=...), anything that can rename inside the batch directory can swap what that name refers to, and the unlink removes the replacement. It cannot be closed while deleting by name: POSIX has no unlink-by-inode, so there is no ordering of fstat and unlink that makes the pair atomic.

The proposed fix does not remove it, and makes the named harm more likely. Fix: Revert the per-entry descriptor deletion hunk returns this to shutil.rmtree. Checked rather than assumed - shutil._rmtree_safe_fd in the stdlib this branch runs against does:

with os.scandir(topfd) as scandir_it:
    entries = list(scandir_it)
        if entry.is_dir(follow_symlinks=False):
    os.unlink(entry.name, dir_fd=topfd)

That is the same check-then-unlink-by-name on the final component, so the identical race exists there. The difference is what happens when it fires: rmtree never consulted a manifest, so a swapped-in unlisted file is deleted unconditionally, while the current code refuses an unlisted file whenever the race does NOT fire and reports SKIP_UNLISTED_FILES. Reverting therefore removes the guard in the common case and keeps the race in the rare one. For this anchor specifically it is strictly worse.

What the residual risk actually requires. An actor able to rename entries inside a batch directory under the user's own data home is running with write access to that tree - the same access needed to read or delete the sessions directly, without waiting for an empty job. So the guard this race bypasses protects against unaccounted files appearing by accident (a partial move, a crash, a stray editor file), which is what SKIP_UNLISTED_FILES was written for, and not against a same-privilege adversary. No name-based deletion in the standard library defends against that adversary either.

I am not posting an override and I am not patching this a 9th time. Two rounds ago the design review said a human should consciously accept the descriptor-rewrite trade; this finding is the same question sharpened - whether an unclosable final-component race is acceptable given the guard buys real protection against accidents and the alternative gives up the guard without closing the race. That is the repo owner's call, and it is now in front of him. If he prefers the revert, the right shape is splitting this PR: the progress fix on rmtree, and the containment work argued on its own merits.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 64c3ad9 - #5191 landing put this branch in CONFLICTING, which no amount of review progress can clear. Three conflicts, all resolved, no behaviour change: both PRs' test blocks kept in SessionStorageScreen.test.tsx (50 pass, main's 7 pagination tests plus this PR's), the React import kept useRef, and .github/black-baseline.txt took the UNION of both deletions - main graduated handlers/sessions.py while this branch graduated handlers/session_storage.py, and my first resolution wrongly re-added main's. The black gate caught that, not me.

GPT and Opus are clean on this head; UX is PASS.

First Principles - description/diff drift. Correct and fixed: item 5 claimed staleTime: 0 + refetchOnMount: 'always' while the diff ships only staleTime: 0. That is exactly the pair your earlier subtraction had me collapse, and I updated the code without updating the description. The body now matches the diff.

First Principles + Design - the security rewrite riding in a progress fix. Both lanes land on the same point from different directions, both say it is not blockable, and both point at the sentence I put in the description offering the split. Nothing for me to change: the offer is real, it is the one decision in this PR, and it belongs to the repo owner rather than to me. It is in front of him now. If he takes the split, the shape is the progress fix on rmtree plus the containment work argued on its own merits.

First Principles - staleTime: 0 siblings, and Design - the one-slot job under reuse. Both accepted-and-deferred, unchanged from my earlier answers. The queryClient default plus no push event is the shared cause, and reuse by restore/staging should be a redesign rather than a copy - recorded so the second user triggers it instead of inheriting a 409 model that stops composing.

Not carried over from the last round and still open for the owner: Design's point that temp-screenshots/*.png is permanent in git history. It is the same call in both PRs, so it wants one answer, not a unilateral edit here.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 17 addressed at 330da39. New finding, not the one I answered last round and not in the delete function - this is the shared name validator.

BLOCKING - an embedded NUL can abort an irreversible delete mid-batch. Real, and I verified all three legs before touching anything: json.loads('"a\u0000b"') does yield a\x00b, so a tampered or corrupted manifest can carry one; _plain_parts("a\x00b") returned ("a\x00b",), i.e. accepted, because my checks only looked for empty, ., .., a backslash and a drive; and os.stat on that name raises ValueError: embedded null character in path, which is NOT an OSError and so fell straight through the handler that exists to skip a bad name. It escaped mid-loop with earlier files already unlinked - a half-deleted batch whose manifest survives, so it stays listed while some of its files are gone.

Fixed in two places, and they are not the same kind of fix:

  1. The validator refuses any component containing "\x00". This one has no trade-off, unlike the drive rule I added two rounds ago: no file name on any platform can contain a NUL, so there is no legitimate name to lose.

  2. Both sites that hand a manifest name to a syscall now catch (OSError, ValueError). The class-level point is that a name this store cannot use must cost its own file and nothing else - an exception escaping that loop stops an irreversible operation partway, which is a worse outcome than any single skipped file.

Being exact about what is verified, because the two halves are not equally covered. test_a_nul_in_a_manifest_name_cannot_abort_a_half_done_delete pins the validator, and I mutation-checked it: removing the "\x00" term reddens the test. The (OSError, ValueError) widening is defence-in-depth with NO test forcing it - the validator makes the only known trigger unreachable, and when I tried to reach it artificially by bypassing the validator, the unlisted-files pre-check refused the batch before the loop ran. So treat item 2 as unexercised belt-and-braces rather than as tested behaviour.

Also in this push: the PR body's item 5 no longer claims refetchOnMount: 'always', which First Principles correctly flagged as describing an earlier draft.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

UX round addressed at 934cbe2. GPT and Opus are clean on the previous head.

Missing diacritics in the three late-added keys. Real and mine. busy_emptying, empty_failed_next_step and empty_not_started were written unaccented while every sibling in those files is accented - including kept_next_step, which I added correctly in an earlier round, so my own additions disagreed with each other. The cause was applying this repo's ASCII rule to the wrong artifact: it governs text published to GitHub, not locale catalog VALUES. Fixed in es/fr/pt, and also in de ("Endgultig loschen" -> "Endgueltig" with the real umlauts) and it ("Non e stato" -> "Non e' stato" with the grave), which the finding did not name but have the same defect.

While fixing them I found one you did not name, same class one layer deeper: the Spanish copy said "Vuelve a intentar Eliminar definitivamente" while the actual button in es.json is "Eliminar permanentemente". It told the user to press something that is not on screen. Now quotes the real label; checked all five against delete_forever rather than assuming.

running.png predates the banner. Correct, and the recapture is a materially better frame: it now shows the banner and the greyed "Preview sweep" adjacent to each other, which is the fix being demonstrated rather than asserted. Your note about the scroll target was exactly right - the page now has two role="status" nodes and querySelector had started returning the new banner, pulling the frame away from the progress row. The harness now takes the LAST one, with a comment saying why.

Suggestion - link "the gateway log" to the Logs page. Agreed it is the better end state and not doing it in this PR. Turning those two sentences into sentence-plus-link means interpolating a component across 12 locales for both kept_next_step and empty_failed_next_step, which is a fresh i18n surface on a PR already long; the strings name the log and the Logs page is one sidebar item away, so the current copy is navigable if not ideal. Recorded rather than silently dropped.

Verification: 162 frontend tests pass (screen + i18n style + key reference), source-strings 0 findings, changed-values 0 catalog QA findings, tsc clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 18 addressed at 381487d. Fixed, not answered - and worth saying why this one got a patch when the round-16 race got an argument, since they look like the same shape.

BLOCKING - the target snapshot does not preserve batch identity. Correct. staged_targets() returned ids, and an id is only a NAME. The mutation lock is released for the async handoff, so a directory moved into an approved name would be opened by that name and deleted on consent the user gave for a different directory - sessions they were never shown.

Why this one is closeable and the file-level race is not: everything after the batch is opened addresses it through dir_fd=batch_fd. So checking the identity OF THAT DESCRIPTOR is airtight - a swap after the check cannot reach the data, because nothing downstream re-resolves the name. The file-level unlink goes by name, and POSIX has no unlink-by-inode, so no fd check helps there. Same shape, different reachability, different answer.

The snapshot now records (st_dev, st_ino) per batch under the lock; empty_trash takes an expect mapping and the delete fstats the descriptor it opened, keeping the batch and reporting a new identity_changed skip on mismatch. Deliberately NOT a second os.stat of the path, which would just be another checkable-then-swappable window. A batch that cannot be stat'd at snapshot time is dropped from the set rather than carried unchecked, and the degraded handler path (snapshot read failed, explicit ids only) passes None with a comment saying the check is skipped and why the exposure is bounded - stated rather than hidden.

test_a_batch_swapped_after_selection_is_kept_not_deleted moves the approved directory aside and copies an impostor into its name, complete with its own manifest so every content-level check passes. It asserts the batch is kept, zero bytes freed, and the impostor still on disk. Mutation-verified: neutering the comparison reddens it, i.e. without the guard the impostor IS deleted.

New skip code rather than reusing unreadable_batch - "could not be read" would be a false label for "was swapped", and this is exactly the class of refusal on_skip exists to surface. kept_identity_changed added in all 12 locales, accented this time.

Verification: 178 backend tests, 162 frontend, mypy and flake8 clean on the changed files, black gate passed, source-strings and changed-values 0 findings, tsc clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 19. Not fixing this one, and not overriding it either - stopping here and handing the decision to the repo owner, which is what I said I would do if this came back.

This is the round-16 finding again. The POSIX half - info = os.stat(...) then os.unlink(...) on the same name - is the identical mechanism, in the identical code, with the identical prescribed remedy ("revert the hunk"). It is answered at #5195 (comment) and I have nothing to add to that answer: the race is real, it cannot be closed while deleting by name because POSIX has no unlink-by-inode, and shutil._rmtree_safe_fd in the stdlib does the same os.unlink(entry.name, dir_fd=topfd) after its own scandir check - so the revert keeps the race and additionally drops the manifest guard that refuses unlisted files whenever the race does NOT fire.

Last round I closed the BATCH-level version of this by carrying (st_dev, st_ino) through the handoff and checking it with fstat. That was closeable precisely because every removal afterwards addresses the batch through dir_fd. The leaf-level unlink addresses a NAME, so the same technique does not transfer. Those are different reachability, not inconsistency on my part.

The Windows observation is new and I am not going to pretend otherwise. shutil.rmtree(batch, ignore_errors=True) performs no identity check, so on Windows the guarantee I added last round does not exist. There is no sound fix available there: without openat/O_NOFOLLOW the only option is stat-the-path-then-rmtree, which is another checkable-then-swappable window - the exact shape I rejected as not-a-fix in my own last comment, and I am not going to ship it here to turn a check red into a check green.

That leaves the real question, which is the one Design and First Principles have both already put in front of a human this round: the hardened delete is POSIX-only, and whether that asymmetry is acceptable - or whether this PR should be split into the progress fix on rmtree plus the containment work reviewed on its own merits - is the repo owner's call, not mine. Three lanes are now converging on the same decision from three directions.

Standing count, for whoever picks this up: 19 review rounds, 9 findings touching the delete path, 2 of them the same race stated twice. Everything else raised on this PR is fixed and dispositioned; the last head carries GPT-clean, Opus-clean, UX PASS results from earlier rounds and this is the only open item. No /ai-review override has been posted at any point.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Split, per the repo owner's decision. Head is now df00da4.

This PR keeps the stdlib rmtree and ships only the progress work. session_storage.py goes from +554 to +252 lines against main. Removed here: _FD_SAFE_DELETE, _open_chain, _open_absolute_nofollow, _clear_by_descriptor, the descriptor branch of _delete_listed_files, the batch-identity plumbing (staged_targets is back to (ids, total), empty_trash loses expect), the identity_changed skip code and its 12 locale strings, and the nine tests that covered those paths.

Kept, because they are the progress feature rather than the security change: the 202+job with a counters-only status read, per-batch progress against a staged total, on_skip refusal codes, the post-condition that the batch is actually gone, measured-not-assumed byte figures (ignore_errors=True returns quietly when a locked file leaves the batch standing), the manifest-derived byte sums, _plain_parts with its absolute/traversal/drive/NUL guards - which stays because the size read goes through it - and the whole frontend, i18n and screenshot set.

The removed work is preserved, not discarded. Branch feat/trash-delete-containment holds it at the reviewed state, and #5430 tracks it with the design, the known limits, and the tests already written. It should land after this one, since it rewrites the function this PR touches.

To be straight about what the split costs: progress is now one report per batch, and on the reported 55,323-session batch that is a single step from nothing to done - which is most of the granularity the original complaint was about. What survives is that the run is now visible at all (a job, a running state, a byte figure, and a stated outcome instead of silence), and that a batch which was NOT deleted says so instead of reading as success. Per-file granularity comes back with #5430.

Also worth recording against my own earlier position: I argued for keeping the two halves together, and three reviewers independently reached the same conclusion the other way - Design ("a human should consciously accept that trade"), First Principles ("take that offer seriously; it is the one real decision here"), and GPT prescribing the revert three times. The convergence was the signal, not any single lane.

Verification on the reduced branch: 169 backend tests pass (test_session_storage.py + test_session_storage_api.py), 162 frontend, mypy and flake8 clean on the changed files, black gate passed, source-strings and changed-values 0 findings, tsc clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Answering this one with a measurement rather than re-adding the removed code, and not overriding it either.

The window this describes already exists on main, with the same per-batch duration. Compared the two directly. Main's _empty_trash_locked:

targets = [_batch_dir(b.batch_id) for b in list_trash()]   # names, built up front
for target in targets:
    resolved = target.resolve()                             # resolved when its turn comes
    ...
    shutil.rmtree(resolved, ignore_errors=True)

This branch is byte-identical in that structure. So on main today, batch N is resolved BY NAME after batches 1..N-1 have been deleted - on the reported 55,323-session store that is minutes after the click. A rename onto an approved name wins there exactly as described here.

What this PR actually adds to that window is the gap between the POST's snapshot and the worker task starting. That task is created with asyncio.create_task in the same handler, so the gap is milliseconds, not the delete duration. The delete duration was already inside the window on main.

And the snapshot makes one thing strictly safer, not less. Main re-enumerates for "empty everything" when the delete RUNS, so a batch staged after the user clicked would be destroyed. staged_targets fixes the set under the lock at request time, so it cannot. That is the opposite direction from this finding.

Closing the name-resolution window needs object-identity binding, which needs fd-based removal. That was in this PR until an hour ago; it is now #5430 with the design, the tests already written, and the branch feat/trash-delete-containment. It was split out because three reviewers - Design, First Principles, and this lane prescribing the revert three times - all said a bespoke security-critical surface should not arrive inside a UX fix. Re-adding it here would undo that decision one hour after the repo owner made it.

On the prescribed remedy: "revert the asynchronous name handoff" removes the reported defect's fix - the whole point is that the delete outlives the request so a user who navigates away is not silently abandoning a half-done irreversible operation - while leaving main's identical per-batch window in place. That trade does not hold up.

To be explicit about what ships unfixed: between the snapshot and each batch's removal, a process with write access to the trash root can retarget an approved name, and this PR does not close that. It is pre-existing, unchanged in shape, and tracked in #5430. No override posted.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 8c1ff41. Zero code edit - the three red Backend Tests shards were a stale base, not a defect.

Backend Tests (3.10, 1), (3.12, 1) and (Windows) (1) were all failing the same test: test_config_baseline.py::TestCommittedBaselineParity::test_committed_snapshot_matches_generator, with "config-baseline.json is out of date with the config schema". This branch touches neither the config schema nor that baseline. Main had moved from 06705bb to 05ea678, and config-baseline.json was changed there by main's own commits - #5114 (whatsapp channel) and #5426, whose title is literally resyncing the generated baseline. The branch was carrying the older snapshot, so the parity guard fired correctly. Rebasing picked up main's regenerated baseline; the test passes locally now (10 passed).

Worth noting the guard did exactly its job here: it is the byte-equality check added in #5164, and it caught a real drift between a branch and the schema rather than a code defect. Nothing to fix on this side.

One local-tooling note in case anyone hits it: scripts/check_black_formatting.py scopes itself on origin/main...HEAD, and with a stale origin/main ref it reported 214 changed files and flagged chat_handlers.py, which this branch never touches. Fetching the ref brought the scope back to 29 files and the gate passes. That was my local ref, not CI.

Verification after the rebase: 169 tests in the two session-storage files, 10 in the config-baseline file, 162 frontend, mypy and flake8 clean on the changed files, black gate passed, source-strings and changed-values 0 findings, tsc clean.

The GPT finding on the previous head is answered at #5195 (comment) - the name-resolution window it describes is present on main today with the same per-batch duration, and closing it needs the fd-based removal now tracked in #5430.

Emptying a staged batch held the request open for the whole delete - minutes on a
store of tens of thousands of sessions - and the screen only greyed three buttons
for the duration. Nothing said it was running, how far it had got, or that it had
finished, and because the inventory query never refetched on mount, leaving the
page and coming back re-rendered the pre-delete list.

Delete files individually with a progress callback, keeping the path-containment
and unlisted-file refusals unchanged; answer 202 with a job and report it from a
counters-only status read that no longer walks the store; land a refusal or an
unexpected error on the job instead of losing it with the request. The screen
polls that job, shows bytes freed against the staged total, says the run survives
leaving the page, and re-reads the inventory once the job settles.

Closes #5194
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

First Principles round addressed at 9ed7ee4. All five items were real leftovers from the split - dead code and stale prose I should have swept when I reduced the PR, plus one screenshot that had quietly become fiction. Design, UX, GPT and Opus are clean/PASS on the previous head.

The mid-run screenshot depicted a state this code cannot produce. The sharpest of the five and the one I would have missed. The fixture staged ONE 18GB batch while the job reported 6.9GB freed - but progress is now per batch, so with a single batch the bar can only be 0% or 100%. The frame was fixture-fed fiction. The fixture now stages THREE batches (6.9GB + 7.1GB + 4.0GB = 18GB, sessions summing to the same 55,323) and 6.9GB is exactly "the first of three finished", which is a figure the shipped code genuinely produces. Comment in the harness says why the count matters, so nobody collapses it back to one.

Recapturing surfaced something unrelated that I am NOT fixing here: a batch with reason: "manual" renders "Moved by manual", which is a pre-existing copy defect in this screen's reason label, untouched by this diff. The fixture uses policy for all three so the frame demonstrates the feature instead of showcasing that bug, and the reason is in a comment rather than silently chosen. Worth its own one-line fix by whoever picks it up.

Subtractions, all confirmed before removing:

  • _PROGRESS_EVERY_FILES - grepped, single hit at its own definition. It paced the per-file loop that left with the split. Gone.
  • _incomplete_if_present's docstring opened "Used by the COARSE path only. The descriptor path knows whether it cleared the tree..." - there is no other path now, so the contrast was describing code that is not here. Rewritten to just say what it does.
  • The spec claimed "Both sites that hand a manifest name to a syscall therefore catch (OSError, ValueError)" - one site remains (_listed_bytes). Corrected to one.
  • The spec's "The rule lives at the / name because..." fragment was half a sentence orphaned by the split. Rejoined into a whole one.

Undeclared items - both now in section 3 of the description as item 6: the paused-controls banner (busy_emptying) and empty_not_started. They were added mid-review in response to UX findings and never made it into the body.

Verification: 169 backend tests, flake8 and mypy clean on the changed file, black gate passed, eslint clean on the harness, three frames recaptured.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

This is the same finding as #5195 (comment), third wording. Not re-adding the removed code, not overriding, and not answering it a third time identically - one new piece of analysis, because the prescribed remedy changed and it deserves checking rather than dismissing.

The new remedy does not close the window it names, and it costs two things. "Keep target validation and deletion in one locked call" means moving staged_targets inside the worker, under the same lock hold as the delete. Walk through what that changes:

  • The lock is threading.Lock in this process. An agent-issued mv does not take it. So holding it across validation and deletion excludes concurrent KiroCrew mutations, not the actor this finding describes.
  • The window for batch N is "however long batches 1..N-1 took to delete", because the loop resolves each batch by name when its turn comes. That is unchanged by where the snapshot happens. One lock hold removes only the POST-to-worker scheduling gap, which is an asyncio.create_task in the same handler - milliseconds against minutes.

So the remedy removes the millisecond part and leaves the minutes part, on a window that main already has in the same shape.

What it costs is real: the 400 empty_refused for a batch id that is not staged, which is currently answered synchronously because the caller can fix the argument, and the progress denominator, which has to exist when the 202 is written or the bar has no total. Both would move behind the 202 and degrade to "find out later".

What would actually close it is binding the delete to the object rather than the name, which needs descriptor-based removal - removed from this PR two hours ago on the repo owner's decision and tracked in #5430. Re-adding it here would undo that decision.

I have now answered this three times and I am not going to keep spending rounds on it. Handing it back: this PR cannot reach green while this lane holds, and the paths to green are an override, or landing #5430 first and rebasing this on top. Both are the owner's call. Everything else on the PR is clean - Design PASS, UX PASS, Opus no blocking, First Principles' five subtractions all applied.

One idea worth recording for #5430 rather than doing here, since it came out of this analysis: renaming each approved batch to a delete-staging name atomically under the lock at request time would narrow the window to the POST handler itself, without descriptors. It is still name-based and so not airtight, but it is materially narrower than what either version does today, and it may be a cheaper first step than the full descriptor walk.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/ai-review override gpt 9ed7ee4: The name-resolution window is already on main with the same per-batch duration, so this PR does not introduce it; the prescribed remedy removes only the millisecond POST-to-worker gap while costing the synchronous 400 and the progress denominator. Binding the delete to the object needs fd-based removal, deliberately split out to #5430 with its tests already written. Answered three times in the thread.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 9ed7ee424a84c58530cf6936b1029aba3ce7907d.

The name-resolution window is already on main with the same per-batch duration, so this PR does not introduce it; the prescribed remedy removes only the millisecond POST-to-worker gap while costing the synchronous 400 and the progress denominator. Binding the delete to the object needs fd-based removal, deliberately split out to #5430 with its tests already written. Answered three times in the thread.

This decision applies only to this commit. A new push requires a new judgment.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

All checks green at 9ed7ee4: 66 passing, none failing, MERGEABLE. Design PASS, First Principles PASS, Opus no blocking, GPT recorded as human override accepted (target=gpt, actor=chenmingwei23) for the name-handoff finding answered three times above and tracked in #5430.

UX's two Watch items on this head are real, they are NOT fixed, and I want them recorded rather than lost - along with why they are not fixed yet.

Both were exposed by my own change one round earlier. When the fixture staged ONE batch, "Restore what you can from this batch" had an unambiguous antecedent and a single dedup'd sentence matched reality. Changing the fixture to three batches - which I did because per-batch progress cannot produce a partial figure with one batch - made the copy wrong in two ways at once: the section-level sentence now says "this batch" above three identical-looking rows with no way to tell which was kept, and [...new Set(job.skipped)] collapses three refusals into one "A batch..." sentence above three survivors. So the fix that made the screenshot honest also revealed that the refusal copy was only ever correct for a single-batch trash.

The right fix is the one UX names: carry the batch id in skipped rather than a bare reason code, and badge the kept row. That is a payload change plus frontend plus i18n - not large, but not a one-liner either, and it is the third item's fix too (startFailed flashing in the gap between a 409 and the next poll).

Why it is not in this push: a new head VOIDS the override just recorded, so shipping these needs another one. That is the repo owner's call, not mine, and it is in front of him now. If he prefers to ship, these want a follow-up issue and should be fixed before the multi-batch refusal path is common - it is the only irreversible surface here, and "restore this batch" pointing at nothing in particular is the wrong instruction to leave on it.

To be explicit about what ships if this merges as-is: a refusal is reported, but on a multi-batch trash it does not say WHICH batch was kept, and two batches kept for the same reason read as one. Neither loses data - the kept batches are all still listed and restorable - but the user has to open the gateway log to know which row to act on.

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.

Session storage: emptying the Trash reports nothing while it runs, and shows a stale list after

2 participants