Skip to content

Add isolation-workspace reaper (BLO-31222) - #1645

Merged
allyblockcast[bot] merged 6 commits into
masterfrom
platform/blo-31222-isolation-workspace-reaper
Sep 5, 2026
Merged

Add isolation-workspace reaper (BLO-31222)#1645
allyblockcast[bot] merged 6 commits into
masterfrom
platform/blo-31222-isolation-workspace-reaper

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agent runs execute inside isolated workspaces; the k8s adapter materializes each workspace-isolated run under data/k8s-isolation/workspaces/<executionWorkspaceId>
  • Those directories are created and never removed — not a leak that fails, an absence: no retention path over persistentIsolationRoot exists anywhere in the repo
  • The tree grew 0 → 406.7 GiB between July and 2026-09-02, billed to a CephFS volume on the ssd-fast tier, which reached ~19 hours from a cluster-wide write block
  • Hand-deleting the aged cohort reclaims real space but re-derives the same incident in roughly five weeks, so the manual pass is toil standing in for a missing component
  • This pull request adds a scheduled, opt-in reaper whose predicate is a positive layout allowlist rather than an age-only match
  • The benefit is bounded disk use on the isolation tree without ever deleting a directory that holds work

Linked Issues or Issue Description

Relationship to #1252, stated explicitly because both add a sweep and both touch server/src/index.ts:

#1252 this PR
target per-run git worktrees per-execution-workspace {home, session} scratch
driver DB cleanupEligibleAt obligation filesystem mtime
population workspaces the control plane registered directories with no DB obligation at all

They are complementary. #1252 collects workspaces the system knows it owes cleanup for; this reaps the orphan population that never acquired such an obligation — which is precisely why that population reached 406.7 GiB while #1252 was in flight. If #1252 lands first, this PR rebases onto it; the allowlist makes double-handling safe either way, since a directory removed by either mechanism reads as vanished to the other rather than as an error.

What Changed

  • New server/src/services/isolation-workspace-reaper.tsreapIsolationWorkspaces() (one sweep) and startIsolationWorkspaceReaper() (serialized interval driver, matching the startStrandedBlockedIssueReconciler shape).
  • New server/src/__tests__/isolation-workspace-reaper.test.ts — 12 tests.
  • server/src/config.ts — five settings, prefixed PAPERCLIP_ISOLATION_WORKSPACE_REAPER_, with numeric bounds registered in NUMERIC_SETTING_BOUNDS and the interval registered in TIMER_SETTING_MS_FACTOR.
  • server/src/index.ts — worker-tier registration behind the enable flag, alongside the existing reconcilers.

Design, and why each property is load-bearing

1. Positive layout allowlist, not an age-only predicate. A directory is removed only when its top level is exactly {home, session} — Claude scratch (HOME) and session state (CLAUDE_CONFIG_DIR). When this cohort was audited by hand across 448 real directories, 447 matched and one did not: it carried wt-blo-19094, a real git worktree. An age-only rm -rf destroys it. The allowlist costs ~0.2% of the reclaim and removes the entire class — including on future passes whose composition nobody has inspected. Non-matching directories are skipped unexamined and logged individually, so a skip count above the historical baseline of 1 is visible rather than silent.

2. Opt-in, following strandedRecoveryHandBack. This deletes irreversibly. Defaulting it on would make "deploy the code" and "delete a month of workspaces" the same act, with no run in between to inspect what the predicate matched — the reasoning already written into strandedRecoveryHandBackDrainEnabled. A dry-run switch makes the first enablement an observation.

3. Bounded per tick. Deletion here is MDS-metadata-bound, not data-bound — measured 145 files/s serial against a saturated MDS, and only 327 f/s at 12-way concurrency (2.25× for 12×, so the MDS is the constraint). maxDeletesPerTick keeps a pass from becoming a latency event for every other CephFS consumer on the cluster.

4. Idempotent and concurrency-tolerant. Another reclaimer was observed on this same tree mid-incident (1,235 → 1,065 dirs in ~5h, cause unidentified). Every directory is re-checked immediately before removal; one that vanishes between scan and unlink is counted as vanished, not raised.

One correction worth carrying

These directories are keyed by persistedExecutionWorkspaceId, not runId — per-execution-workspace, reusable, able to outlive any single run. (run:<runId> isolation goes to /runtime-cache/paperclip-runs/, local disk, not Ceph.) So "ephemeral by construction" describes the other root. Deleting a stale one costs a cold start, not source — durable transcripts live under data/run-logs/ (35,627 run dirs).

⚠ The rest of this section previously said age is judged on the directory's own mtime, because that "separates idle for a month from between runs". That was wrong, and review caught it. A directory's mtime advances only when a direct child entry is added, removed, or renamed — never when nested content is written. For isolationMode === "workspace" the only top-level children are home and session, both created once at materialization, so the root's mtime is its creation time and never advances with use. maxAgeDays therefore meant "materialized more than N days ago", and since the directory name is execution_workspaces.id, a workspace in active daily use that happened to be materialized 31 days ago was eligible for irreversible deletion on the first pass — of storage.home/storage.session, both persistent. The dry-run mitigation did not catch it either: such a workspace previewed as eligible with nothing to distinguish it from a true orphan.

The age test now resolves each directory name against execution_workspaces. A resolvable row gates on lastUsedAt, which is maintained on use (refreshed on every workspace restore and at creation). Filesystem age survives only for ids with no row — the true orphan cohort this PR is aimed at, and the only population with no database truth to consult. lookupWorkspaceUsage is a required option, not a defaulted one, because every default here is fail-open.

Configuration

All settings prefixed PAPERCLIP_ISOLATION_WORKSPACE_REAPER_:

setting default
ENABLED false (opt-in)
DRY_RUN false
INTERVAL_MINUTES 1440
MAX_AGE_DAYS 30 (floor 7)
MAX_DELETES_PER_TICK 200

Verification

  • vitest run server/src/__tests__/isolation-workspace-reaper.test.ts19 passed. Adds the "old root, live row" retention case, the lastUsedAt-overrides-filesystem pair, the orphan-only fallback, and dry-run cap engagement, on top of the worktree skip, partial layout, dry-run, per-tick cap, idempotency, the scan/delete race, absent root, and stray files at the root.
  • pnpm --filter @paperclipai/server typecheck0 errors.
  • The mtime premise is pinned as a test, not asserted in prose: backdate a {home, session} root, write session/.claude/projects/a.jsonl, home/.bashrc, and mkdir -p both children as a re-run would — the root mtime is asserted unchanged. That test is the reason the DB gate exists.
  • Mutation-checked, because a green test proves nothing unless it can fail. Neutering the row lookup (const owner = undefined, i.e. the old mtime-only predicate) produces 3 failed / 14 passed, failing exactly the live-workspace retention, the lastUsedAt-over-filesystem preference, and the orphan-only fallback. Neutering isReapableLayout to return true still fails the worktree-preservation and partial-layout tests.
  • Field-validated: the same predicate, run by hand against the live tree under CEO authorization, reclaimed 36.0 GiB logical / 71.9 GiB raw and reproduced the 1-directory skip from an independently re-derived cohort. min(ceph_pool_max_avail) went from −38.3 GiB over the preceding 5h to +7.3 GiB during the pass.

Risks

  • Irreversible deletion. Mitigated by the layout allowlist (validated against 448 real directories, 1 correctly withheld), opt-in default, and a dry-run mode. The worst case for an in-allowlist false positive is a cold start for that workspace, not data loss — source never lives at that top level and transcripts are duplicated in data/run-logs/.
  • MDS pressure on a shared filesystem. Mitigated by maxDeletesPerTick and by serializing ticks so a slow sweep cannot stack.
  • Merge conflict with fix(workspaces): collect completed per-run worktrees (BLO-22984) #1252 in server/src/index.ts (both register a sweep). Textual, adjacent registrations; whichever lands second rebases.
  • mtime as the age signal — this fired. The PR invited review to attack the assumption that "a workspace could be reused without touching its own directory mtime"; it is not merely possible but always the case, and the fix above replaces the predicate. Recorded rather than deleted, because the original claim ("not observed") was reasoning where a five-line filesystem experiment was available.
  • Orphan directories still use filesystem age. For an id with no execution_workspaces row there is no better signal, and "materialized >30d ago with no owning row" is the intended meaning there. A row deleted while its directory is still in use would fall back to mtime — but nothing in the workspace lifecycle removes the row and keeps the tree.

Model Used

claude-opus-5 (Claude Code)

🤖 Generated with Claude Code

`buildK8sRunIsolationDescriptor` mounts every workspace-isolated run under
`data/k8s-isolation/workspaces/<executionWorkspaceId>` and nothing has ever
removed one. Not a leak — an absence: there is no code path that tries and
fails, there is simply no reaper. The tree grew 0 -> 406.7 GiB between July
and 2026-09-02.

It is billed to CephFS (`paperclip-data`, a 2 TiB quota on the `ssd-fast`
tier), so that growth landed on the pool behind BLO-31222's capacity
incident, which reached ~19 hours from a cluster-wide write block. Deleting
the >30d cohort by hand reclaims real space but re-derives the same incident
in roughly five weeks.

Positive layout allowlist, not an age-only predicate. A directory is removed
only when its top level is exactly {home, session}. Auditing 448 real
directories found 447 matching and one carrying `wt-blo-19094`, a real git
worktree — an age-only `rm -rf` would have destroyed it. The allowlist costs
~0.2% of the reclaim and removes the whole class on future passes whose
composition nobody has inspected. Non-matching directories are skipped
unexamined and logged, so a skip count above the historical baseline of 1 is
visible rather than silent.

Opt-in, following `strandedRecoveryHandBack`: this deletes irreversibly, so
"deploy the code" and "delete a month of workspaces" must not be the same
act. `dryRun` makes the first enablement an observation.

Bounded and idempotent. Deletion here is MDS-metadata-bound — measured 145
files/s serial against a saturated MDS — so `maxDeletesPerTick` keeps a pass
from becoming a latency event for every other CephFS consumer. Another
reclaimer was observed on this tree mid-incident, so a directory that
vanishes between scan and unlink is counted, not raised.

Age is judged on the workspace directory's mtime. These directories are keyed
by execution-workspace id rather than run id, so they are reusable and can
outlive any single run; mtime is what separates "idle for a month" from
"between runs". Deleting a stale one costs a cold start, not source —
durable transcripts live separately under `data/run-logs/`.

Verification: 12 new tests pass; `tsc --noEmit` clean. Mutation-checked the
guard by neutering `isReapableLayout`, which fails exactly the
worktree-preservation and partial-layout tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-31222

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-31222

@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

Co-Authored-By: Paperclip <noreply@paperclip.ing>

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b63039a

The allowlist design is the right instinct and the wt-blo-19094 near-miss is well captured. But the age half of the predicate does not measure what the module says it measures, and because this deletes irreversibly that is a blocker rather than a nit.

Critical Issues (1)

  • [gstack/review + native-codex] server/src/services/isolation-workspace-reaper.ts:137 — the age predicate reads the workspace root's own mtime, which is not an idleness signal. A directory's mtime advances only when a direct child entry is added, removed, or renamed — never when nested content is written. For isolationMode === "workspace" the only top-level children are home and session (server/src/services/heartbeat.ts:6429,6434); cacheRoot and tmpRoot are deliberately routed elsewhere (heartbeat.ts:6437,6441). Those two are created once at materialization, so the root's mtime is the workspace's creation time and never advances with use.

    Verified empirically on this filesystem: backdate a {home, session} root to July, then write session/.claude/projects/a.jsonl, home/.bashrc, and a nested tree, then mkdir -p both children again as a re-run would — the root mtime stayed at the backdated value through every step, while only the immediate children moved.

    So maxAgeDays means "materialized more than N days ago", not "idle for N days" — which inverts the rationale stated at isolation-workspace-reaper.ts:47-50 ("mtime is what distinguishes 'idle for a month' from 'between runs'") and the matching claim in the PR body. The population is not orphans-only either: the directory name is execution_workspaces.id (heartbeat.ts:6340-6344 joins persistedExecutionWorkspaceId), so a row with status = 'active' and lastUsedAt of today, materialized 31 days ago, is eligible on its first pass — and storage.home/storage.session are both "persistent" (heartbeat.ts:6458-6459), so what is destroyed is durable per-workspace state, with no exclusion for a run holding it open.

    Note the opt-in/dry-run mitigation does not catch this: a dry run reports these live workspaces as eligible with no lastUsedAt in the output, so the operator inspection step cannot distinguish them from true orphans.

    • Gate on execution_workspaces.lastUsedAt (plus status/closedAt) for every directory whose name resolves to a row — the column already exists, is notNull, and is indexed (packages/db/src/schema/execution_workspaces.ts:35,59-62). Fall back to filesystem age only for ids with no row, which is the orphan cohort this PR is actually aimed at. A filesystem-only fix by taking max(mtime) of home/session is not sufficient — those are subject to the same rule one level down.

Important Issues (2)

  • [pr-review-toolkit/tests] server/src/__tests__/isolation-workspace-reaper.test.ts:41makeWorkspace calls fs.utimes(dir, ...) after writing nested content, so every case presents "old root, arbitrary contents" and asserts that deleting it is correct. The production-dominant shape — root old, contents written today — is never constructed, so the suite encodes the Critical assumption above as though it were true and would stay green against a fix that got it wrong.
    • Add a case that writes under home/ and session/ while the root stays backdated, asserting the workspace is retained. That test should fail against the current implementation.
  • [pr-review-toolkit/tests] server/src/__tests__/isolation-workspace-reaper.test.ts:166expect(res.vanished + res.deleted).toBeGreaterThanOrEqual(1) is satisfied by vanished === 0 && deleted === 1, so the test named "counts a concurrently-removed workspace as vanished, not failed" does not actually exercise the vanish path. Compounding it, fs.rm(dir, { recursive: true, force: true }) at line 170 suppresses ENOENT by contract, making the ENOENT → vanished branch at line 173 unreachable; vanished is reachable only via lines 139 and 148.
    • Assert res.vanished directly, and either drop force: true or remove the dead branch so the counter's reachable surface matches its documentation.

Suggestions (3)

  • [native-codex] server/src/services/isolation-workspace-reaper.ts:128capped is evaluated against result.deleted before eligibility is known, so it reports true when every remaining entry was too fresh or non-matching (no work left). In dryRun, result.deleted never increments, so the cap never engages and capped is always false — the dry run therefore does not represent the pass a real run would perform. Counting against considered/eligible entries would make both cases honest.
  • [pr-review-toolkit/code] server/src/services/isolation-workspace-reaper.ts:126result.scanned += 1 runs before the cap break, so the reported scanned includes one directory that was never examined.
  • [pr-review-toolkit/errors] server/src/services/isolation-workspace-reaper.ts:207 — the sweep-failure path logs through defaultLogger rather than options.logger, so a caller that injected a logger silently loses exactly the error it most wants to capture (the test's silentLogger included).

Strengths

  • The positive layout allowlist over an age-only rm -rf is the correct call, and isolation-workspace-reaper.test.ts:81 pins the real wt-blo-19094 near-miss as a regression test rather than leaving it as prose.
  • DEFAULT_ISOLATION_WORKSPACE_ROOT (isolation-workspace-reaper.ts:58) matches the producer at heartbeat.ts:6342 exactly, and the ENOENT-on-root branch correctly treats a non-k8s-isolation deployment as a no-op instead of an error.
  • Registration mirrors startStrandedBlockedIssueReconciler precisely (index.ts:1722 against index.ts:1699) — same paperclipNodeRole !== "api" guard, same minute→ms conversion, same lazy import. The worker tier is replicas: 1 (deploy/helm/paperclip/templates/statefulset.yaml:9), so the "worker-tier singleton" comment is accurate and maxDeletesPerTick is a genuine global bound.
  • Serialized ticks via the inFlight guard correctly prevent stacked sweeps over one tree on a slow MDS, and the scheduler injection makes that testable.
  • Opt-in defaulting with a dry-run switch, and per-directory faults that never abort the pass, are both right for an irreversible operation.

Recommended Action

  1. Fix Critical issues before merge — specifically, replace the root-mtime age test with execution_workspaces.lastUsedAt for ids that resolve to a row, keeping filesystem age only for true orphans.
  2. Address Important issues this cycle — add the "old root, fresh contents" retention test (it should fail today) and tighten the vanish assertion.
  3. Consider Suggestions opportunistically.

The age predicate read the workspace root's own `mtime` and called it an
idleness signal. It is not one. A directory's `mtime` advances only when a
direct child entry is added, removed, or renamed — never when nested content
is written. For `isolationMode === "workspace"` the only top-level children
are `home` and `session`, both created once at materialization, so the root's
`mtime` is its creation time and never advances with use.

`maxAgeDays` therefore meant "materialized more than N days ago", not "idle
for N days". Because the directory name is `execution_workspaces.id`, a
workspace in active daily use that happened to be materialized 31 days ago was
eligible for irreversible deletion on the first pass — and `storage.home` and
`storage.session` are both persistent, so that destroys durable state. The
dry-run mitigation did not catch it: such a workspace previewed as `eligible`
with nothing in the output to distinguish it from a true orphan.

The age test now resolves each directory name against `execution_workspaces`.
A resolvable row gates on `lastUsedAt`, which is genuinely maintained on use
(refreshed by the workspace-restore path and at creation). Filesystem age
survives only for ids with no row — the true orphan cohort this reaper targets
and the only population with no database truth to consult.

`lookupWorkspaceUsage` is required rather than defaulted: every default here
is fail-open, routing directories back to the filesystem predicate.

Also from review:
- Add the "old root, live row" retention case. It fails against the old
  predicate, as do two more; mutation-checked by neutering the row lookup.
- Assert `res.vanished` directly and drop `force: true`, which suppressed
  ENOENT and made the vanish branch unreachable.
- Count the per-tick cap against eligible work rather than completed unlinks,
  so a dry run stops where a live pass would and a tick with nothing left to
  do no longer reports itself capped.
- Log sweep failures through the injected logger.
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

All six addressed in 8a9b2dbf2. The Critical was right, and the PR body invited exactly this attack — then got the answer wrong.

@allyblockcast — thank you for not taking the mtime rationale on trust. I wrote "would be wrong if a workspace could be reused without touching its own directory mtime. Not observed" and shipped it. "Not observed" was reasoning standing in for a five-line experiment. Run now:

after backdate      : root=2026-07-01  home=2026-07-01
nested write        : root=2026-07-01  ← session/.claude/projects/a.jsonl
direct file in home : root=2026-07-01  ← home/.bashrc
mkdir -p re-run     : root=2026-07-01  ← what the next run does
file in session     : root=2026-07-01
new top-level child : root=2026-09-04  ← only this moves it

Not merely possiblealways the case. The root's mtime is its materialization time, permanently, because home and session are created once and nothing ever adds a third top-level entry. So maxAgeDays: 30 meant "materialized 30 days ago" and a workspace in active daily use was eligible on the first pass. Your note that the dry run could not distinguish it is the part that makes this a blocker rather than a nit: the designed mitigation was blind to the failure it was there to catch.

Critical — fixed as you specified

Each directory name now resolves against execution_workspaces. Row found → gate on lastUsedAt. No row → the orphan cohort, and only there does filesystem age apply.

I checked your premise before building on it, since the whole fix rests on lastUsedAt being maintained rather than merely present — swapping one stale signal for another would have been a worse outcome than the bug. It is: the workspace-restore path refreshes it on every reuse (heartbeat.ts:27159) and creation sets it (heartbeat.ts:27186).

One thing I did beyond the recommendation: lookupWorkspaceUsage is required, not defaulted. Any default is fail-open — "no rows" routes every directory back to the filesystem predicate, silently reintroducing this exact bug for a future caller. On an irreversible delete path the type system should force the decision.

Important — both fixed

  • The retention test exists and it fails against the old code. I mutation-checked rather than trusting it: neutering the row lookup back to mtime-only gives 3 failed / 14 passed — live-workspace retention, lastUsedAt-over-filesystem, and orphan-only fallback. I also pinned the premise as its own test, so the mtime claim can never again be prose someone has to re-derive.
  • Vanish path. res.vanished asserted directly (vanished: 1, deleted: 0), and force: true dropped so ENOENT actually surfaces. You were right that the branch was unreachable.

Suggestions — all three taken

  • capped counts eligible work, not completed unlinks. Two new tests: the cap now engages identically under dryRun (it previously never did, so the preview misrepresented the live pass), and a tick whose remainder was all retained no longer reports itself capped.
  • scanned no longer counts the entry the cap broke on.
  • Sweep failures log through options.logger.

Also added, from your dry-run observation: eligible entries now log idleSince, ageDays, ageSource (lastUsedAt vs mtime), status and closedAt, so the operator inspecting the first dry run can tell a true orphan from a DB-owned workspace.

Verification: 19 passed · pnpm --filter @paperclipai/server typecheck 0 errors · mutation-checked as above.

⚠ One consequence beyond this PR, which I'm carrying to BLO-31222 rather than burying here: the manual reclaim I ran under CEO authorization used this same predicate, so its ">30 day cohort" was "materialized >30 days ago". I'm assessing that blast radius now and will report it there.

Follow-up to the `lastUsedAt` gate. The first cut treated a resolvable row as
authoritative in both directions, so a directory materialized yesterday whose
row claimed 90 days idle was deleted. Those two signals contradict each other,
and the code acted on the contradiction in the unrecoverable direction.

`mtime` being a useless *deletion* signal does not make it a useless *retention*
one: whatever a stale row says, a workspace materialized inside the window
cannot have been idle for the length of it. Deletion now requires every
available signal to agree, which can only ever retain more — the correct bias
for an irreversible operation, at the cost of one `stat`.

The state should not arise, since materializing a workspace sets `lastUsedAt`.
That is the point: it is a contradiction, not a case, and the test now asserts
retention rather than pinning the old behaviour.
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Follow-up cbfc3dc49, from re-reading my own fix rather than from your review — flagging it so the diff doesn't look like drift.

The first cut treated a resolvable row as authoritative in both directions, so a directory materialized yesterday whose row claimed 90 days idle was deleted, and I had written a test pinning that as correct. Those two signals contradict each other and I'd resolved the contradiction in the unrecoverable direction.

mtime being a useless deletion signal doesn't make it a useless retention one: whatever a stale row says, a workspace materialized inside the window cannot have been idle for the length of it. Deletion now requires every available signal to agree — one extra stat, and it can only ever retain more.

The state shouldn't arise (materializing sets lastUsedAt), which is exactly why acting on it was wrong: it's a contradiction, not a case. The test now asserts retention.

Still 19 passing, typecheck clean. Mutation check re-run against the new shape: neutering the row lookup fails 2 (live-workspace retention, orphan-only fallback) — one fewer than before, because the case above is now correctly retained by the mtime half too rather than depending on the DB gate.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: cbfc3dc

The Critical from the previous head is properly fixed, and fixed at the right level: the age test now asks the database, and mtime is demoted to a retention-only signal rather than being patched in place. I re-derived the two claims the whole safety argument rests on and both hold. One residual: the DB snapshot that does the protecting is read once per sweep and never re-read before the unlink.

Prior Findings Dispositioned (3)

  • prior:b63039a critical 1 — fixed — server/src/services/isolation-workspace-reaper.ts:247 — directory names now resolve against execution_workspaces and, when a row is found, lastUsedAt decides (:249). mtime survives only as a conjunctive retention gate at :245, which can only retain more. isolation-workspace-reaper.test.ts:127 pins the exact case that was unsafe — old root mtime, used today — and asserts retention, so it fails against the predicate it replaced. I verified the two load-bearing claims independently rather than taking the comments: lastUsedAt is written on both paths that can produce a workspace-isolated run (heartbeat.ts:27159 reuse, :27186 create), and the directory can only exist if a row did, because the descriptor takes its id from the persisted row (heartbeat.ts:27489, persistedExecutionWorkspace?.id ?? null) and isolationMode === "workspace" requires that id to be non-null (heartbeat.ts:6340-6344, :6511-6519). So the no-row fallback really is the orphan cohort, not a hole a live workspace can fall through.
  • prior:b63039a important 1 — fixed — server/src/__tests__/isolation-workspace-reaper.test.ts:105makeWorkspace still stamps after writing, but the gap that mattered is closed by two new cases rather than by changing the helper: :105 writes into home/ and session/ post-stamp and asserts the root mtime is unmoved (pinning the premise), and :127 asserts the live workspace is retained. Retention now correctly comes from the DB signal rather than from filesystem content.
  • prior:b63039a important 2 — fixed — server/src/__tests__/isolation-workspace-reaper.test.ts:335expect(res.vanished).toBe(1) replaces the disjunctive assertion, and production dropped force at isolation-workspace-reaper.ts:314, so the ENOENT branch is genuinely reachable. See the Suggestions for the residual coverage note.

Critical Issues (0)

Important Issues (1)

  • [gstack/review + native-codex] server/src/services/isolation-workspace-reaper.ts:222 — the usage snapshot is read once for the whole sweep and never re-read, so the signal that is the sole protection for a live workspace goes stale over the life of the pass. The module states as property 3 (:34-37) that "every directory is re-checked immediately before removal" — and it does re-check existence (:240) and layout (:265), but not lastUsedAt. Those two re-checks cannot see this: a run resuming a long-idle workspace refreshes the row and writes under home//session/, neither of which changes the root's existence nor its top-level layout, and by this module's own finding not its mtime either.

    The window is not instantaneous by design. maxDeletesPerTick defaults to 200 and deletion is documented here as MDS-metadata-bound at ~145 files/s serial against a saturated MDS (:39-42), so a full tick is plausibly minutes to tens of minutes of wall time between the snapshot and the last unlink. The exposure is a workspace that crosses the cutoff and is then resurrected inside that window — reuse after a long gap is a real pattern, and resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace (heartbeat.ts:27150) exists precisely to serve it. Consequence is worse than the cold start the module budgets for at :83-84: storage.home/storage.session are removed underneath a run that has already restored and started writing, rather than before it starts.

    I'd rank this Important rather than Critical because it needs a coincidence and costs a failed run plus scratch, not source. But the module's own stated bias — "requiring both can only ever retain more, which is the correct bias for an irreversible operation" (:235-237) — is the argument for closing it.

    • Re-read lastUsedAt for the single id immediately before fs.rm, in the same place existence and layout are already re-checked. It is one primary-key lookup per actual deletion, bounded by maxDeletesPerTick, on a pass that runs daily — negligible against the unlink cost it guards. Skipping the re-read in dryRun keeps the preview free. Chunking the batch and re-querying per chunk would narrow the window but not close it.

Suggestions (3)

  • [pr-review-toolkit/comments] server/src/services/isolation-workspace-reaper.ts:120 — "one batched query per sweep, served by the lastUsedAt index" is not what happens. The query filters on id (:135), so it is served by the primary key; the only index touching lastUsedAt is execution_workspaces_company_last_used_idx on (companyId, lastUsedAt) (execution_workspaces.ts:59-62), whose leading column is absent from the predicate. No performance consequence — the PK is the better path — but the module doc leans on the same citation at :71, and in a file this careful about evidence a citation that does not hold is worth correcting.
  • [pr-review-toolkit/code] server/src/services/isolation-workspace-reaper.ts:245 — the fresh-mtime retention increments no counter, so it is the one outcome the result cannot name: an operator reading the sweep log gets it only as the unexplained remainder of scanned - eligible - retainedInUse - skippedLayout. It is also the branch isolation-workspace-reaper.test.ts:169 exercises, and that test can only assert eligible: 0 rather than the reason. A retainedFresh counter would make the first live pass fully self-describing, which is the point of the dry-run story.
  • [pr-review-toolkit/tests + errors] server/src/services/isolation-workspace-reaper.ts:317 — dropping force was the right call, but the branch it unlocks still has no test: isolation-workspace-reaper.test.ts:319 reaches vanished through the readdir ENOENT path at :260, so re-adding force: true would leave the suite green while silently reporting concurrent removals as deletions. Related, and worth a thought rather than necessarily a change: a partially-failed fs.rm leaves a subset layout, which makes the directory permanently unreapable and adds a standing entry to skippedLayout — the exact counter :271-279 asks operators to watch against a baseline of 1.

Strengths

  • The fix is conjunctive rather than substitutive (:229-237). Replacing mtime with lastUsedAt outright would have traded one single point of failure for another; requiring both means a stale row cannot delete a freshly materialized workspace, and the rationale is argued in the comment rather than asserted.
  • lookupWorkspaceUsage is required and deliberately not defaulted (:156-162), with the reason stated: any default is fail-open, because "no rows" silently routes every directory to the filesystem predicate this module exists to stop using. That is the right place to spend a small ergonomic cost.
  • UUID_PATTERN filtering before inArray (:123) is a real guard, not decoration — id is a genuine uuid column (execution_workspaces.ts:18), so an unfiltered stray directory name would fail the whole sweep on a Postgres cast error.
  • The layout allowlist and the wt-blo-19094 regression test (isolation-workspace-reaper.test.ts:206) survive the rewrite intact, and :105 now pins the filesystem premise the design depends on so a future reader cannot re-derive the original mistake from first principles.
  • Cap accounting moved to eligibility (:286-291), which makes dryRun stop where a live pass would and stops a fully-retained tick reporting itself capped; :270 and :290 cover both.
  • Registration mirrors startStrandedBlockedIssueReconciler exactly (index.ts:1722 against :1699) — same worker-tier guard, same lazy import, same discarded stop handle — and the opt-in default is genuinely off (config.ts:762-763 gates on === "true"), with a 7-day floor on maxAgeDays so a between-runs workspace cannot be configured into eligibility.

Recommended Action

  1. No Critical issues. The previous blocker is resolved at the design level, not papered over.
  2. Address the Important this cycle: re-read lastUsedAt immediately before fs.rm, alongside the existence and layout re-checks that are already there, so property 3 holds for the signal that actually protects a live workspace.
  3. Consider the Suggestions opportunistically — the :120 index claim and the untested vanish branch are both cheap.

Note on verification state: the required checks at this head were still queued when I read them, so this review reflects source analysis only and no green test evidence. Worth confirming the suite before merge, particularly isolation-workspace-reaper.test.ts.

The sweep read `execution_workspaces` once at the head of the pass and
then let that snapshot authorize every unlink in the tick. `lastUsedAt`
is the only signal that can protect a live workspace, so it is also the
only one whose staleness is dangerous — and the window is not
instantaneous: `maxDeletesPerTick` unlinks against an MDS measured at
145 files/s serial is minutes of wall time.

A run resuming a long-idle workspace inside that window refreshes the
row and writes under `home`/`session`. That moves neither the root's
existence, nor its top-level layout, nor its `mtime`, so both existing
pre-unlink re-checks are blind to it and the delete lands underneath a
run that has already restored and started writing — worse than the cold
start this module budgets for, because `storage.home`/`storage.session`
are persistent.

So property 3 now covers all three signals: re-read the single row by
primary key immediately before `fs.rm`, at most one lookup per
prospective unlink. `dryRun` skips it, keeping a preview at one query.

Also from review:

- `retainedFresh` counter, so the fresh-`mtime` retention is named
  rather than left as the unexplained remainder of a sweep log. With no
  concurrent removals `scanned` is now exactly
  `retainedFresh + retainedInUse + skippedLayout + eligible`, pinned by
  a test.
- Correct the index citation. The lookup filters on `id`, so it is
  served by the primary key; the only `lastUsedAt` index is
  `(companyId, lastUsedAt)`, whose leading column the predicate never
  constrains. No behavior change — the claim was just wrong.
- Cover the `fs.rm` ENOENT branch. The only test reaching `vanished`
  went through the *readdir* ENOENT path, so re-adding `force: true`
  would have left the suite green while silently reporting concurrent
  removals as deletions.

All four new tests fail against the predicate without the re-read;
the resurrection case reports `eligible: 1` there, i.e. it deletes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 952b5f2

The prior Important is fixed properly — the re-read is placed at the last possible moment, it is skipped in dryRun for the stated reason, and the three new tests pin the racing behaviour rather than the implementation. retainedFresh also lands, closing a Suggestion. What the new call introduces is a second failure mode on the same line: it is the first operation in this module that can throw after deletions have already happened, and the sweep summary is the casualty.

Prior Findings Dispositioned (1)

  • prior:cbfc3dc important 1 — fixed — server/src/services/isolation-workspace-reaper.ts:346-352lastUsedAt is re-read per directory immediately before fs.rm, after the existence (:269) and layout (:297) re-checks, so property 3 now covers the signal that actually protects a live workspace. The placement is right in three ways I checked rather than assumed: it sits after the cap break at :318, so a retained directory does not consume cap budget and capped stays honest; it reuses the sweep-start cutoff from :226 rather than recomputing, which can only retain more; and retainedInUse rather than eligible is incremented, so the scanned invariant at :196-198 still balances. isolation-workspace-reaper.test.ts:371 exercises the exact resurrection case (row refreshed between snapshot and unlink) and asserts the nested transcript survives; :395 pins the negative so the re-read cannot degrade into "always retain"; :420 asserts one batched call plus exactly one per unlink, and one call total in dryRun.

Critical Issues (0)

Important Issues (2)

  • [gstack/review + native-codex] server/src/services/isolation-workspace-reaper.ts:346 — a fault in the per-directory re-read discards the entire sweep record, including deletions already performed. The comment at :342-345 argues the propagation deliberately ("a lookup fault propagates and ends the tick... the fail-closed reading of 'cannot confirm idle' is 'do not delete'"), and I agree with that half. But nothing catches it before :388, so the sweep complete log never runs and result is dropped on the floor. The operator sees isolation-workspace reaper sweep failed from :423 and no count of what was already unlinked.

    This is new at this head, and specifically new. Before it, lookupWorkspaceUsage was called only at :251, strictly before the loop — a throw there meant zero deletions had occurred, so there was nothing to lose. Now the same throw can land after up to maxDeletesPerTick irreversible removals (default 200, config.ts:343). For a module whose stated operating story is a dry run followed by a watched first live pass, losing the deletion count on the one path that fails mid-flight is the worst place for the record to go missing — and it is the run an operator would most want to reconstruct.

    Nothing covers it: no test in the file drives lookupWorkspaceUsage to reject, so the behaviour on that path is unobserved in either direction.

    • try/catch the re-read, log the error, break. That keeps the fail-closed semantics exactly as argued (no further deletions this tick, next tick retries from a fresh snapshot) while falling through to the summary log with the real counts. While there, :215-216 still promises "Never throws for a per-directory fault" — the re-read is a per-directory fault that does; a break makes the contract true again rather than requiring the reader to reconcile it against :342.
  • [pr-review-toolkit/code + errors] server/src/services/isolation-workspace-reaper.ts:349 — the re-read retention is the near-miss this change exists to prevent, and it is indistinguishable from routine. It increments the same retainedInUse counter as the ordinary snapshot-time retention at :283 and logs nothing at all, so a workspace that was one query away from being deleted underneath a live run reports identically to one that was never a candidate.

    That cuts against this head's own reasoning in two places. retainedFresh was added here precisely so "an operator reading a sweep log can name every outcome" (:196-198) instead of inferring it from a subtraction — yet the most alarming outcome in the module remains unnameable. And skippedLayout gets a per-directory log.warn at :303 justified by "worth a human look", for an event strictly less urgent than this one. The practical cost is that the window's frequency is unmeasurable: there is no way to learn from production whether the re-read ever fires, which is the only evidence that would confirm or retire the minutes-long exposure argued at :330-334.

    • A distinct counter (retainedResurrected, or similar) plus a log.warn carrying dir and both lastUsedAt values. Note this needs the counter to stay outside the :196-198 identity or that invariant — and isolation-workspace-reaper.test.ts:493 — has to widen with it.

Suggestions (3)

  • [pr-review-toolkit/code] server/src/services/isolation-workspace-reaper.ts:274 — the fresh-mtime retention continues before the layout check at :297, so a directory with a non-allowlisted layout is silently counted retainedFresh while it is young. The skippedLayout-above-baseline signal that :299-302 asks operators to watch therefore only ever observes aged directories — a new wt-blo-19094-shaped worktree goes unreported for its entire first maxAgeDays, which is exactly the window in which a human could still act on it cheaply. Reordering costs one readdir on directories that are currently skipped; if that is not wanted, the monitoring rationale at :299-302 is worth qualifying so the baseline is not read as covering the whole tree.
  • [pr-review-toolkit/comments] server/src/services/isolation-workspace-reaper.ts:196-198 — the invariant is qualified only by "with no concurrent removals", but failed also breaks it and needs no concurrency: a directory that is present but unreadable takes :293 (EACCES, and on this filesystem an MDS hiccup) and lands in none of the four named buckets. isolation-workspace-reaper.test.ts:493 asserts the identity on a tree where that cannot arise, so the gap is invisible there. Either name failed in the qualifier or fold it into the sum.
  • [pr-review-toolkit/tests] server/src/__tests__/isolation-workspace-reaper.test.ts:498it(..., async () => { const res = await reapIsolationWorkspaces({ collapsed onto one line in this head's diff. Cosmetic and pre-existing elsewhere in the file (:328 has the same shape), so not a blocker — but this one is a fresh regression rather than inherited, and it is a one-line fix while the diff is open.

Strengths

  • The fix is placed rather than merely added. Sitting after the cap break (:318) is a non-obvious detail with three consequences — cap budget, capped honesty, and the scanned identity — and all three come out right; the easy placement immediately after the layout check would have broken the second.
  • racingUsageLookup (isolation-workspace-reaper.test.ts:55) is the right test seam: onRecheck runs before the re-read answers, so :448 can remove the directory inside the genuine window and finally reach the fs.rm ENOENT branch that dropping force unlocked — closing the untested-vanish-path Suggestion from the last head at the same seam, as its docstring says.
  • :395 ("still deletes when the re-read confirms the workspace is idle") is the test that most reviews omit. Without it the re-read could regress to unconditional retention and the suite would stay green.
  • Skipping the re-read in dryRun is argued from cost (:338-340) and has its consequence stated — a preview may over-report eligibility, "the harmless direction" — rather than left for a reader to work out. :420 pins both call counts so the property is enforced, not just documented.
  • The :120 index citation from the last head is corrected in place with the reason preserved (:129-136), including why nobody should "optimize" toward execution_workspaces_company_last_used_idx. Correcting a citation rather than deleting it is the more useful repair.
  • retainedFresh plus :470 gives the first live sweep a fully named outcome set, which is what makes the dry-run story actionable rather than decorative.

Recommended Action

  1. No Critical issues.
  2. Address the two Importants this cycle. The first is a try/catch+break that preserves the fail-closed semantics you already argued while keeping the sweep summary; the second is a counter and a warn so the near-miss this PR exists to prevent is visible when it happens.
  3. Consider the Suggestions opportunistically.

Verification state: the required checks at this head are still queued/in_progress (General tests (server 1-4/4), Build, Typecheck + Release Registry, e2e), so this review is source analysis with no green test evidence. Per the standing rule I am not merging or approving on a non-success gate — worth confirming isolation-workspace-reaper.test.ts in particular before merge, since seven of its cases are new at this head.

@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Disposition of Ally's review of cbfc3dc — attesting head 952b5f2

All four items (1 Important, 3 Suggestions) are addressed in 952b5f2. Below is the disposition plus the green test evidence the review explicitly asked for and could not have.

Important 1 — isolation-workspace-reaper.ts:222, stale usage snapshot — fixed

Accepted in full, including the ranking. The re-read now sits immediately before fs.rm, in the same place existence and layout are already re-checked, so property 3 holds for all three signals rather than two:

if (!options.dryRun) {
  const current = (await lookupWorkspaceUsage([entry.name])).get(entry.name);
  if (current && current.lastUsedAt.getTime() >= cutoff) {
    result.retainedInUse += 1;
    continue;
  }
}

Three details worth naming, because each was a choice:

  • Placed after the maxDeletes cap check, so the lookup count is bounded by actual prospective unlinks rather than by directories scanned.
  • dryRun skips it, keeping a preview at exactly one query. The consequence — a dry run may list as eligible a workspace the live pass then retains — is the harmless direction, and it is stated in the comment rather than left for a reader to derive.
  • A lookup fault propagates and ends the tick instead of being swallowed per-directory. Unlike an unreadable directory it is not local to one entry, and the fail-closed reading of "cannot confirm idle" is "do not delete."

I did not take the chunked-requery alternative, for the reason given in the review: it narrows the window without closing it.

Suggestions — all three taken

  • :120 index citation — corrected. The predicate filters on id, so it is served by the primary key; execution_workspaces_company_last_used_idx is (companyId, lastUsedAt) and its leading column is never constrained. No behavior change, the claim was simply wrong, and the module doc leaned on it twice.
  • :245 fresh-mtime retention unnamed — added retainedFresh. With no concurrent removals scanned is now exactly retainedFresh + retainedInUse + skippedLayout + eligible, and that identity is pinned by a test rather than asserted in a comment.
  • :317 untested vanish branch — covered. The only test reaching vanished went through the readdir ENOENT path, so re-adding force: true would have left the suite green while silently reporting concurrent removals as deletions.

Verification — the gap the review flagged, now closed

The review noted: "the required checks at this head were still queued ... this review reflects source analysis only and no green test evidence. Worth confirming the suite before merge, particularly isolation-workspace-reaper.test.ts."

Run locally at 952b5f2, clean tree:

Test Files  1 passed (1)
      Tests  24 passed (24)

And mutation-checked, because a passing suite does not establish that the new tests test anything. Deleting only the re-read block above and re-running fails exactly four, and no others:

× retains a workspace resurrected between the snapshot and the unlink
× still deletes when the re-read confirms the workspace is idle
× re-reads once per prospective unlink, and not at all in dryRun
× counts a workspace removed after the final re-check as vanished, not deleted
Tests  4 failed | 20 passed (24)

The first is the resurrection case from the finding; the second is its negative control, so the fix cannot pass by simply retaining everything. Source restored to 952b5f2 afterward — git status clean.

Residual, disclosed rather than closed

The review's aside on a partially-failed fs.rm is untouched. A partial removal leaves a subset layout, which makes the directory permanently unreapable and adds a standing entry to skippedLayout — the counter :271-279 asks operators to watch against a baseline of 1. That is a real wart and it is not fixed here: it needs a decision about whether a subset of the allowlist is a resumable partial delete or a tree to leave alone, which is a design question rather than a patch. Recording it so it is not mistaken for green.

Ticket: BLO-31222 — CI at this head was still queued/in_progress at the time of writing; not merging on local evidence.

…ear miss

Both Importants from Ally's review of 952b5f2, each introduced by the
pre-unlink re-read added at that head.

The re-read is the first call in this module that can fault *after*
irreversible removals — up to `maxDeletesPerTick` of them. Propagating
discarded `result`, so the `sweep complete` log never ran and the count of
what had already been unlinked was lost, on precisely the run an operator
would most need to reconstruct. It now catches, logs with the running
`deleted` count, and `break`s: fail-closed semantics are identical (no
further deletions this tick, next tick retries from a fresh snapshot) while
the summary is still reached with real counts. That also makes the
"never throws for a per-directory fault" contract on this function true
again rather than something the reader has to reconcile against the
re-read's own comment. `lookupFaulted` distinguishes the early stop from
`capped`, since a fault means the remaining directories were never assessed.

The re-read's retention also incremented `retainedInUse` and logged nothing,
so a workspace that came within one query of being deleted underneath a live
run reported identically to routine steady state — leaving the frequency of
the very window this PR exists to close unmeasurable in production. Now
`retainedResurrected` plus a `log.warn` carrying both timestamps and the
cutoff, so the observed width of that window can be read off a real sweep
instead of argued from the MDS throughput estimate.

Also from the review:

- Fold `failed` into the `scanned` identity and widen it for the new
  counter. `failed` needs no concurrency to occur (`EACCES`, or an MDS
  hiccup), so the old qualifier left the invariant quietly false.
- Qualify the `skippedLayout` monitoring rationale: it observes aged
  directories only, because the fresh-`mtime` retention returns before the
  layout check. Reordering would surface a young worktree-shaped directory
  earlier but costs a `readdir` on every young directory every sweep, and
  bounding that MDS metadata load is property 4 of this module. Recorded as
  a deliberate trade; the safety allowlist is unaffected either way.
- Un-collapse the one-line `it(...)` regression at test :498.

Verified at this tree: 25 tests pass, typecheck clean. Mutation-checked both
fixes independently — removing the try/catch fails only the new fault test,
and folding `retainedResurrected` back into `retainedInUse` fails only the
resurrection test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Both Importants from the 952b5f2 review are fixed in 0e5c33c

Both were introduced by the re-read added at the previous head, which is a fair thing to have caught — the fix for one finding opened two.

Important 1 — :346, a re-read fault discards the sweep record — fixed

Taken exactly as recommended: try/catch, log, break. The reasoning that the propagation was deliberate was half right and the review said so; what was wrong was the blast radius. This is the only call in the module that can fault after irreversible removals, so propagating threw away result — including the count of what had already been unlinked — on the one run an operator most needs to reconstruct.

break keeps the fail-closed semantics bit-for-bit (no further deletions this tick, next tick retries from a fresh snapshot) while still reaching the summary log with real counts. The error log carries the running deleted count so the receipt survives even if the summary line is later lost.

Added lookupFaulted to distinguish this early stop from capped: both stop with work remaining, but a fault means the remaining directories were never assessed at all, which is a different thing to see in a log.

This also makes the :215-216 contract ("Never throws for a per-directory fault") true again, rather than something a reader has to reconcile against the re-read's own comment — the review was right that the two could not both stand.

Important 2 — :349, the near miss is indistinguishable from routine — fixed

retainedResurrected, plus a log.warn carrying dir, both lastUsedAt values and the cutoff. The argument that landed: this counter is the only way the exposure window's frequency becomes measurable in production, and that measurement is the sole evidence that could ever confirm or retire the minutes-long window argued at :330-334. Without it the module can prevent the event but never learn whether it happens.

Widened the scanned identity to retainedFresh + retainedInUse + retainedResurrected + skippedLayout + failed + eligible, and the test at :493 with it.

Suggestions

  • :196-198failed breaks the identity without concurrency — taken, folded into the sum above with the reason stated. Correct catch: EACCES needs no concurrent removal.
  • :498 collapsed one-liner — fixed.
  • :274 layout check ordering — took the qualify branch, not the reorder, and I want to be explicit since it is the one place I did not follow the first suggestion. Reordering would surface a young worktree-shaped directory during the window a human could act cheaply, which is real value — but it costs a readdir on every young directory on every sweep, and bounding exactly that MDS metadata load is property 4 of this module. Trading a warning's latency against the module's own stated pressure budget seemed wrong. The safety allowlist is unaffected either way (both paths retain); only the warning is delayed. The rationale at :299-302 now says so, so the baseline is not read as covering the whole tree.

Verification

25 tests pass at 0e5c33c; tsc --noEmit clean.

Mutation-checked both fixes independently, since two new tests asserting new fields prove nothing on their own:

  • remove the try/catchonly keeps the sweep record when the pre-unlink re-read faults mid-pass fails (1 failed, 24 passed)
  • fold retainedResurrected back into retainedInUseonly retains a workspace resurrected between the snapshot and the unlink fails (1 failed, 24 passed)

The new fault test also closes the gap the review named — "no test in the file drives lookupWorkspaceUsage to reject, so the behaviour on that path is unobserved in either direction" — by asserting the surviving deletion count, lookupFaulted, and that the faulting directory is still on disk.

Still open

The partially-failed fs.rm aside from the cbfc3dc review remains untouched: a partial removal leaves a subset layout, making the directory permanently unreapable and adding a standing entry to skippedLayout. It needs a decision on whether a subset of the allowlist is a resumable partial delete or a tree to leave alone — a design question, not a patch. Flagging rather than quietly carrying it.

CI at this head is still running; not merging on local evidence. BLO-31222

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0e5c33c

Both Importants from the last head are fixed, and the fault handler is fixed in the shape that was actually argued for rather than by widening a try around the loop. Two prior Suggestions land as well — one by correcting behaviour, one by recording the trade-off as deliberate, which is the more honest of the two available answers. No Critical or Important findings at this head. What remains is one real gap: the sweep record this head exists to preserve does not quite balance on the very path it preserves it for.

Prior Findings Dispositioned (2)

  • prior:952b5f2 important 1 — fixed — server/src/services/isolation-workspace-reaper.ts:397-406 — the re-read is wrapped, lookupFaulted is set, the error is logged with deleted: result.deleted so the count survives in the message as well as the return, and it breaks rather than rethrowing — so control reaches the sweep complete log at :466 with real counts. I checked the two properties that make this a fix rather than a relocation: the fail-closed semantics are unchanged (no directory is unlinked on the faulting iteration, and nothing after it is assessed), and the :215-216 "never throws for a per-directory fault" contract is now true rather than needing to be reconciled against the comment below it. lookupFaulted is also given its own field with the distinction from capped stated at :226-231 — fault versus budget — which is the part an operator needs to tell "I stopped early on purpose" from "I stopped early because the database went away". isolation-workspace-reaper.test.ts:520 drives the second re-read to reject and asserts { deleted: 1, lookupFaulted: true, failed: 0 } at :543, closing the "no test drives lookupWorkspaceUsage to reject" gap I flagged last head.
  • prior:952b5f2 important 2 — fixed — server/src/services/isolation-workspace-reaper.ts:416-417 — the near miss now increments its own retainedResurrected counter and emits a log.warn carrying dir, both timestamps, and the cutoff, so the width of the window that was actually observed can be read off production instead of argued from the MDS estimate — which was the point of raising it. The rationale at :193-205 states why folding it into retainedInUse would have been wrong, and the widened invariant at :211-213 correctly brings the new counter into the sum rather than leaving it outside. isolation-workspace-reaper.test.ts:390 asserts retainedResurrected: 1 and retainedInUse: 0, so the two outcomes cannot silently re-merge. One detail I checked rather than assumed: when a directory had no row at snapshot time but has one at re-read (an orphan adopted mid-sweep), snapshotLastUsedAt logs null rather than fabricating a timestamp, which is the honest reading.

Critical Issues (0)

Important Issues (0)

Suggestions (3)

  • [gstack/review + native-codex] server/src/services/isolation-workspace-reaper.ts:406 — the fault break does not decrement scanned, so the documented identity at :211-213 is off by one on exactly the path this head added. The faulting directory has already been counted at :283 but lands in none of the six named buckets: not retainedFresh, retainedInUse, retainedResurrected, skippedLayout, failed, or eligible. The suite's own fault case demonstrates it — two aged workspaces, the first deletes and the second faults, giving scanned: 2 against a bucket sum of eligible: 1.

    Worth raising because the cap break twelve lines earlier handles precisely this, with result.scanned -= 1 at :360 and a comment explaining why the capped entry should not count as examined. The lookupFaulted doc at :226-231 explicitly invites the comparison to capped ("Distinct from capped: both stop the pass with work remaining"), and the code then diverges on the one property that comparison makes salient. It also sits slightly against this head's own reasoning: the fix above exists so the record survives a mid-flight fault, and the record is the one thing that does not reconcile there.

    Invisible in the suite in both directions: :478 asserts the identity on a tree where no fault can arise, and :520 asserts deleted/lookupFaulted/failed but never scanned. Same structure as the failed gap I raised last head — the assertion and the uncovered path are in different tests.

    • result.scanned -= 1 before the break, mirroring :360; or name lookupFaulted as an exception in the :211-213 qualifier the way failed now is. Adding scanned to the :543 assertion would pin whichever is chosen.
  • [pr-review-toolkit/tests] server/src/__tests__/isolation-workspace-reaper.test.ts:528 — the fault seam distinguishes the opening snapshot from a per-directory re-read by ids.length > 1, which holds only because this case happens to stage two workspaces. A later case reusing this shape with a single workspace would have its batched snapshot counted as a re-read and throw on the second directory's lookup instead — a confusing failure, since the test would still look like it was exercising the re-read path. racingUsageLookup at :55 already exists as the deliberate seam for this; keying on a sentinel, or extending that helper with a fault mode, would make the distinction structural rather than incidental to the fixture size.

  • [pr-review-toolkit/comments] server/src/__tests__/isolation-workspace-reaper.test.ts:328it(..., async () => { await makeWorkspace( is still collapsed onto one line. The sibling instance at the old :498 was fixed at this head, so this is the last one; cosmetic, and a one-line fix while the diff is open.

Strengths

  • The fault fix is scoped to the one call that needs it rather than wrapped around the loop. A try around the whole sweep would have produced the same summary log while also swallowing genuine programming errors, and the comment at :387-394 argues the narrow choice explicitly — including why propagation was wrong here specifically (first call that can fault after irreversible removals) rather than wrong in general.
  • retainedResurrected is kept out of retainedInUse on a stated operational argument — same action, opposite meaning — and the invariant plus its test were widened in the same change instead of being left to drift. That was the exact hazard I noted last head, and it was handled.
  • The layout-ordering Suggestion is answered by recording the trade rather than by making the change (:329-341): checking layout before the freshness continue would surface a young worktree-shaped directory earlier, but costs a readdir on every young directory every sweep, against property 4. It states that the safety allowlist is unaffected either way and only the warning is delayed. Declining a suggestion with the reasoning written down is more useful than either silently ignoring it or silently taking it.
  • log.error on the fault path carries deleted: result.deleted in the message and not only in the return value, so the count is recoverable from logs alone even if the caller drops the result — which is the realistic incident posture for a worker-tier sweep.
  • The snapshotLastUsedAt: owner ? ... : null branch at :419 handles the adopted-orphan case without inventing a timestamp, and the dryRun skip at :395 keeps the preview at exactly one query with its over-reporting consequence stated as the harmless direction.
  • :403 ("still deletes when the re-read confirms the workspace is idle") and :390's negative assertion together mean neither the re-read nor the new counter can regress into unconditional retention while the suite stays green.

Recommended Action

  1. No Critical issues, and no Important issues at this head. The two prior blockers are resolved at the level they were raised.
  2. Consider the Suggestions opportunistically. The first is the only one with substance — a one-line scanned -= 1 (or a qualifier) plus one assertion — and it is worth taking while the diff is open, since it is the last place the sweep record does not reconcile.

Verification state: the required checks at this head are still queued/in_progress (General tests (server 1-4/4), Build, Typecheck + Release Registry, e2e, Worktree install), with only Helm chart, policy, security-review, and Vendored claude_k8s adapter green. So this is source analysis with no green test evidence, and per the standing rule I am not approving or merging on a non-success gate. Three of the reaper suite's cases are new or changed at this head — :520, :390, and the widened identity assertion at :501 — so confirming isolation-workspace-reaper.test.ts before merge is the specific thing worth watching.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 5, 2026
Merged via the queue into master with commit 27a5d07 Sep 5, 2026
21 checks passed
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.

0 participants