Skip to content

fix(session): restore disk offloading for managed-session resident data (RAM/CPU bloat in long sessions) - #3344

Merged
Yeachan-Heo merged 9 commits into
devfrom
research/RAM-RSS-regressions
Jul 28, 2026
Merged

fix(session): restore disk offloading for managed-session resident data (RAM/CPU bloat in long sessions)#3344
Yeachan-Heo merged 9 commits into
devfrom
research/RAM-RSS-regressions

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Root cause

Long-running sessions grew RSS monotonically after recent updates:

  1. PR fix(coding-agent): support ACL-less managed sessions #2724 (89244b877) disabled disk offload for default sessions. #resetResidentTextBlobStore gained a destination.kind === "explicit" guard, but default sessions are managed (main.tsmanagedDestination). Every externalized text payload ≥1KB was pinned in the in-RAM MemoryBlobStore (64MiB/4096-entry LRU) — with silent placeholder content loss past the cap.
  2. Strong #materializedEntriesCache / #sessionContextCache (from 6f585f84f / 7b44e5783) pinned additional fully-inflated transcript copies.

Fix (5-pass ralplan consensus plan; Architect CLEAR/APPROVE + Critic OKAY)

  • Verified per-profile cache root: getResidentCacheRootDir(profileAgentDir) with XDG routing; profileAgentDir preserved on destination security contexts (nested destinations use the parent profile root, never artifact-subtree authority). lstat/O_NOFOLLOW chain verification, mkdtemp nonce instance dirs, owner-only modes.
  • Hardened blob I/O: O_EXCL|O_NOFOLLOW 0600 fd writes; EEXIST lstat+hash verify = dedup success; foreign/unverifiable → typed trust error with whole-store demotion (transcript rehydration with SHA-256 matching beyond the 8MiB buffer LRU) + single append retry; verified fd-bound cache-miss reads.
  • Two-phase transition seam: #prepareResidentTextStoreTransition/#commitResidentTextStoreTransition is the sole store/entry swap point with per-call-site failure policies (install-staged / retain-and-throw / memory-fallback / memory-only). Fork records publication before identity capture with byte-matched exact cleanup; setSessionFile validates fully before mutating; memory-guard promotion prepares before source removal; fork manifests prune legacy resident-cache/ subtrees.
  • Degraded-session correctness: canonical (unbounded) MemoryBlobStore ownership for fallback stores — no LRU loss of live content; staged refs validated before commit; read-only loads defer local-root provisioning on EACCES/EPERM/EROFS.
  • Bounded materialized caches: 32MiB size-aware retention (strong below cap, weak above); #resetMaterializedCaches now also clears the session-context cache.
  • Lease-aware bounded GC: owner.json pid+start-time tokens (PID-reuse ABA-safe), quarantine no-follow deletion, ≤64 dirs/250ms async budget.
  • win32 fail-closed gate: Windows never creates a cache dir (keeps MemoryBlobStore, counter incremented); native test wired into windows-dev-doctor CI.
  • Bench harness: packages/coding-agent/scripts/resident-memory-bench.ts (rss / put-latency / read-churn, validity preconditions, HEAD-baseline modes).

Acceptance evidence

Check Result
Heap pinning (the actual bug) Eliminated — fresh-open heapUsed delta 11.9MiB; fresh-open RSS 153.6MiB better than base
Content integrity Zero placeholders under red-team C1–C8 incl. 63/64/65MiB and 4095/4096/4097-blob boundaries
Read-churn (AC-3) ≤2× forced-rebuild baseline, valid runs only
Focused suites 247 pass / 7 skip / 6 fail — all 6 failures pre-existing on base 3649db42e (verified by stash-swap)
Gates 3-pass architect review → zero blockers; AI-slop cleaner PASS; red-team blockers (C2/C4) fixed with repro re-verification

Documented deviations (architect-ruled ship-with-documented-deviation):

  • AC-1 absolute 100MiB steady-state RSS gate: branch measures 139.3MiB, but the unchanged base measures 122.6MiB on the identical harness — the gate is a calibration defect. Marginal cost is +16.7MiB of non-reclaimable allocator high-water (0 bytes GC-reclaimable both sides; external/arrayBuffers bounded), not live references. Follow-up: recalibrate AC-1 baseline-relative.
  • AC-2 1.5× put-latency micro-ratio: direct store-put ratio 38.8× vs a pure heap Map insert (baseline artifact); absolute 0.065ms/put, 10,199 appends/s end-to-end, fsync 0.

Deferred (user-confirmed)

  • Windows disk backing (fail-closed gate until identity-bound ACL verification lands)
  • #inMemoryArtifacts LRU bound for persist=false SDK sessions
  • getEntries() per-call deep-clone CPU reduction

Plan artifacts: .gjc/_session-019fa2a3-961a-7000-a74c-44035a3b916c/plans/ralplan/.../pending-approval.md; red-team report: artifacts/resident-cache-redteam-report.json.

Long-running sessions grew RSS monotonically because PR #2724 (89244b8)
restricted the disk-backed resident-text EphemeralBlobStore to explicit
destinations. Default sessions are managed, so every externalized text
payload (>=1KB) was pinned in the in-RAM MemoryBlobStore (64MiB/4096 LRU)
with silent placeholder content loss past the cap, compounded by strong
materialized-entry/session-context caches pinning fully inflated
transcript copies.

Fix architecture (5-pass ralplan consensus, stage-03/04/05 revisions):

- Verified per-profile resident-cache root: getResidentCacheRootDir(
  profileAgentDir) with XDG cache routing; profileAgentDir preserved on
  destination security contexts (default getAgentDir(), parent profile
  root for nested; never rootAuthority.canonicalPath). lstat/O_NOFOLLOW
  chain verification, mkdtemp nonce instance dirs, owner-only modes.
- Hardened puts: O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW 0600 fd writes;
  EEXIST lstat+hash verify = dedup success; foreign/unverifiable ->
  typed ResidentCacheTrustError with whole-store demotion (transcript
  rehydration with SHA-256 matching beyond the 8MiB buffer LRU) and a
  single append retry. Verified cache-miss reads (fd-bound, fstat
  uid/mode, content hash).
- Two-phase transition seam: #prepareResidentTextStoreTransition /
  #commitResidentTextStoreTransition as the sole store/entry swap point
  with per-site policies (install-staged, retain-and-throw,
  memory-fallback, memory-only). Fork records publication before
  identity capture with byte-matched exact cleanup (File + custom
  SessionStorage); setSessionFile validates fully before mutating;
  memory-guard promotion prepares before source removal; cache-pruned
  fork manifests skip legacy resident-cache subtrees.
- Degraded sessions use canonical (unbounded) MemoryBlobStore ownership
  so fallback never LRU-drops live content; staged refs validated
  before commit. Read-only session loads defer local-root provisioning
  on EACCES/EPERM/EROFS instead of failing.
- Bounded materialized caches: MATERIALIZED_CACHE_MAX_BYTES=32MiB
  size-aware retention (strong below cap, WeakRef above);
  #resetMaterializedCaches now also clears the session-context cache.
- Lease-aware bounded GC: owner.json pid+start-time tokens (ABA-safe),
  quarantine no-follow deletion, <=64 dirs/250ms async budget.
- win32 fail-closed gate: never creates a cache dir, keeps
  MemoryBlobStore, counter incremented; native test wired into
  windows-dev-doctor CI.
- Bench harness scripts/resident-memory-bench.ts (rss/put-latency/
  read-churn with validity preconditions and HEAD baseline modes).

Acceptance evidence: heap pinning eliminated (fresh-open heapUsed delta
11.9MiB; fresh-open RSS 153.6MiB better than base); zero content loss
under red-team C1-C8 incl. 63/64/65MiB and 4095/4096/4097-blob
boundaries; append steady-state RSS 139.3MiB vs the plan's 100MiB gate
is a documented deviation - the unchanged base measures 122.6MiB on the
identical harness (gate calibration defect), marginal +16.7MiB is
non-reclaimable allocator high-water, not live references.

Deferred (user-confirmed): Windows disk backing (fail-closed gate until
identity-bound ACL verification lands); #inMemoryArtifacts LRU bound;
getEntries() per-call deep-clone cost.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1439fd1098

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


function measureFsyncCalls<T>(operation: () => T): { result: T; fsyncCalls: number } {
const require = createRequire(import.meta.url);
const mutableFs = require("node:fs") as typeof import("node:fs");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use a top-level fs type import in the benchmark

Replace typeof import("node:fs") with a top-level namespace import and use that namespace's type here. This newly added inline type import directly violates the repository's no-inline-import contract, including its explicit prohibition on import("pkg").Type.

AGENTS.md reference: AGENTS.md:L65-L68

Useful? React with 👍 / 👎.

Comment on lines +6335 to +6337
} catch (error) {
if (destinationSnapshot) {
const removed = native.exactRemoveDirectoryTree(finalDestinationDir, destinationSnapshot);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clean partial explicit fork artifacts after copy failures

If fs.promises.cp() fails after creating part of the destination, or if the first destination snapshot fails, destinationSnapshot is still undefined and this catch skips cleanup. copyArtifactsForFork() then throws before returning a publication record, so fork() also has no authority to remove the partial artifact directory, leaving orphaned data after a failed explicit-session fork. Capture and exact-remove the partial tree in this path as well.

Useful? React with 👍 / 👎.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Adversarial review — PR #3344 @ 1439fd109812840d4c2b8a6a1f95826dd3484fdc (base dev @ 69817fb36)

Read-only review. Nothing mutated, nothing merged.

Overall the security core of this change is genuinely strong: the resident-cache trust model (O_NOFOLLOW | O_DIRECTORY verified descriptors, re-check-path-against-descriptor after every mkdir, owner-only uid/mode gates, fstat identity re-validation around every read/write, quarantine-rename-then-removeResidentCacheTreeNoFollow instead of rm -rf on unverified paths, compare-before-delete GC with pid+lstart incarnation probing, and a bounded sweep at 64 dirs / 250 ms) is careful, fails closed, and resisted the symlink/TOCTOU/PID-reuse attacks I threw at it. The Windows disk gate, pruneResidentCacheEntries fork filtering, and the canonical-vs-cache MemoryBlobStore ownership split are all coherent. But there is one PR-introduced functional regression that blocks merge.

Blocking

B1 — Cross-workspace managed session resume is broken (regression vs dev).
setSessionFile() gained a new pre-flight guard at packages/coding-agent/src/session/session-manager.ts:5406-5414. When the candidate transcript lives in a different managed session directory than the current destination, it builds a candidate store via managedStoreFromContext(this.destination.securityContext, candidateDirectory). That path reuses securityContext.retainedAuthority with authorityBaseDir: candidateDirectory, but the retained authority is bound to the current session subtree — so #assertBound() (internal/managed-session-storage.ts:616) compares the retained dev/ino against the candidate directory's and throws.

Reproduced deterministically (3/3 runs) at exact head, and confirmed passing on dev:

# PR head 1439fd1: two managers, same agentDir, different cwd, managed destination
cross-workspace resume B -> A:
  ERR: Managed descendant root binding changed     # 3/3 runs

# baseline dev 69817fb36, identical script
BASELINE OK: setSessionFile succeeded on dev

Same-cwd setSessionFile still succeeds, which is why most suites stay green — but resuming a session from another workspace/project is exactly the common gjc --resume / session-switch path, and it now hard-fails.

This is not theoretical: the PR's own adversarial test catches it and is red at exact head.

bun test --timeout 60000 test/ultragoal-redteam-resident-cache.test.ts
  (fail) C2 retains a predecessor on candidate failure and degrades to memory for EACCES, EPERM, and EROFS
    Expected substring: "Resident cache trust validation failed"
    Received message:   "Managed descendant root binding changed"
  8 pass / 1 fail

The guard throws the managed-binding error before resident-cache trust logic is ever reached, so the C2 degradation path it is supposed to exercise is not actually being tested. Repair scope: derive the candidate store for a foreign directory without reusing the current subtree's retained authority (or skip/relax the retained-authority binding when candidateDirectory !== destination.directory), then confirm C2 asserts the resident-cache trust error it was written for.

B2 — check:tools / package check fails at exact head (PR-introduced).

packages/coding-agent/test/session-manager-resume-readonly.test.ts format
  × Formatter would have printed the following content:   # multi-line import of @gajae-code/utils
Found 1 error.

Verified PR-introduced: the same file is clean on dev. This gates both bun run check:tools at the root and bun --cwd=packages/coding-agent run check. Run bun run fmt:ts. (The provider-auth-health.redteam.test.ts useLiteralKeys info is pre-existing on dev, not yours.)

Should fix

S1 — Codex P1 stands: inline type import violates the repo contract.
packages/coding-agent/scripts/resident-memory-bench.ts:781

const mutableFs = require("node:fs") as typeof import("node:fs");

AGENTS.md:66 forbids both halves of this ("no await import(), no import(\"pkg\").Type, no dynamic type imports"). It is only unflagged by biome because check:tools's tsconfig scope does not cover it. Use the top-level import * as fs from "node:fs" namespace already available for the type, and keep createRequire only for the mutable binding.

S2 — Codex P2 stands: partial explicit-fork artifacts leak on early copy failure.
session-manager.ts:6305-6348. If fs.promises.cp() throws after creating part of the destination tree — or if the destination snapshot itself fails — destinationSnapshot is still undefined, so the catch skips exactRemoveDirectoryTree entirely. copyArtifactsForFork() then throws before returning a publication record, so fork()'s #cleanupForkArtifactPublication (6255-6262) never sees it either. Result: an orphaned partial artifact directory at finalDestinationDir with no owner. The managed path stages into .{name}.{uuid}.fork-staging and is fine; only the explicit branch has this hole. Capture and exact-remove the partial tree in that path too.

Non-blocking observations

  • O1 — Zero CI at exact head. gh api .../commits/1439fd1.../check-runs -> total_count: 0; .../actions/runs?head_sha=1439fd1... -> 0; combined status pending. For a 6,467-line change to session lifecycle, nothing has been verified by CI. B1 and B2 would both have been caught. Please get a green dev-ci run at head before merge.
  • O2 — The 1,340-line RSS bench is unreferenced. resident-memory-bench.ts is a genuinely good harness (subprocess process.memoryUsage() sampling, forced-GC reclaim deltas, AC1 summarization, a --force-memory-only control arm) and it does measure real RSS rather than mocks — credit where due. But grep -rn "resident-memory-bench" across workflows/docs/package scripts returns nothing, so no RAM/RSS claim in this PR is regression-gated. Consider a bench:resident-memory script plus a threshold check, or the bloat can silently return.
  • O3 — Test-suite RSS coverage is behavioral, not memory-quantitative. The new suites assert store selection, trust rejection, GC reaping, and fallback counters — correct and valuable — but no test fails if resident RAM regresses. The RSS claim rests entirely on the manually-run bench in O2.
  • O4 — MemoryBlobStore canonical mode is deliberately unbounded (blob-store.ts, #store returns early before the LRU trim when #canonical). The reasoning in the comment is sound: a degraded session has no trusted disk store behind its refs, so eviction would corrupt reads. Just noting the accepted tradeoff — on Windows, or after an EACCES/EPERM/EROFS degradation, a very long session's resident text is retained in RAM with no cap, which is the exact scenario this PR targets. Worth an explicit note in docs/blob-artifact-architecture.md.
  • O5 — residentCacheProcessStartTimeMs shells out to ps via Bun.spawnSync per unknown pid during GC. It is cached per-pid and per-process, LC_ALL=C pinned, and bounded by the 64-dir/250 ms sweep budget, so worst case is ~64 short-lived ps calls — acceptable, and it mirrors the existing file-lock probe. Flagging only as a known CPU floor on pathological cache roots.
  • O6 — macOS/Linux divergence is handled; Windows is gated off disk entirely (#newResidentTextStoreCandidate returns a canonical MemoryBlobStore and bumps residentCacheWin32FallbackCount), with resident-cache-win32-gate.windows.test.ts wired into dev-ci. Good. Note that test is describe.skipIf(process.platform !== "win32"), so it is a no-op everywhere else — its value depends entirely on the Windows CI leg actually running, which ties back to O1.

Commands run (exact head 1439fd1, worktree clean throughout)

git rev-parse HEAD                                  -> 1439fd109812840d4c2b8a6a1f95826dd3484fdc
gh pr view 3344 --json headRefOid                   -> 1439fd109812840d4c2b8a6a1f95826dd3484fdc (re-verified before posting)
git rev-parse origin/dev                            -> 69817fb365c570f1bae618bd16be610e4f65d302
bun --cwd=packages/natives run build                -> ok (addon built locally; not committed)

bun test test/session-resident-root-security.test.ts \
         test/session/resident-cache-gc.test.ts \
         test/session-resident-cache-root-derivation.test.ts \
         test/session-resident-cache.test.ts \
         test/session-resident-lifecycle.test.ts    -> 41 pass / 0 fail

bun test --timeout 60000 \
         test/session-materialized-cache-retention.test.ts \
         test/blob-store-bound.test.ts \
         test/session-manager-resume-readonly.test.ts \
         test/session-resident-transition-seam.test.ts  -> 70 pass / 0 fail

bun test --timeout 60000 test/ultragoal-redteam-resident-cache.test.ts
                                                    -> 8 pass / 1 FAIL (C2)  [B1]
bun test --timeout 60000 test/agent-session-openai-responses-replay.test.ts
                                                    -> 21 pass / 0 fail
bun run check:types (packages/coding-agent)         -> clean
bun run check:tools (root)                          -> FAIL (format)         [B2]

Note on the replay suite: at the default 5 s timeout it showed 9-14 failures, but dev reproduces the same failures identically and all 21 pass at --timeout 60000. Those are environmental slowness on this host, not attributable to this PR. The C2 failure is the opposite — deterministic at head, absent on dev, and reproducible outside the test harness.

Verdict

REQUEST_CHANGES

Keep the PR open. Repair scope is bounded and does not require redesign — the trust model itself is sound and should be preserved as-is:

  1. B1 — fix the setSessionFile cross-directory candidate-store construction so foreign managed directories do not reuse the current subtree's retained authority; then confirm ultragoal-redteam-resident-cache.test.ts C2 goes green asserting the resident-cache trust error it was written for.
  2. B2bun run fmt:ts to clear the session-manager-resume-readonly.test.ts format error.
  3. S1 — replace the inline typeof import("node:fs") in the bench with the top-level namespace type.
  4. S2 — exact-remove partial explicit-fork artifacts when cp() or the destination snapshot fails before destinationSnapshot is assigned.
  5. O1 — land a green dev-ci run at the exact head before merging.


[repo owner's gaebal-gajae (clawdbot) 🦞]

Yeachan-Heo added 6 commits July 28, 2026 00:29
…ssion-offloading-review-blockers

Integrate dev at pinned SHA fbb5670.

Conflict resolution: .github/workflows/dev-ci.yml conflicted in two places
(the affected-evidence-producer and affected job env blocks). Both were
resolved by union, keeping this PR's expanded CI_DEV_WINDOWS_DOCTOR_REQUIRED
expression (which adds blob-store.ts, session-manager.ts and
resident-cache-win32-gate.windows.test.ts to the Windows doctor trigger)
together with dev's new CI_DEV_WINDOWS_NATIVE_TOOLCHAIN_RESULT and
CI_DEV_WINDOWS_NATIVE_TOOLCHAIN_REQUIRED entries. Neither side's coverage
was dropped.

packages/coding-agent/src/internal-urls/docs-index.generated.ts was
regenerated with `bun run generate-docs-index` rather than hand-merged,
because the file is generated and marked DO NOT EDIT.

Verified after the merge:
- `getResidentCacheRootDir` is present in packages/utils/src/dirs.ts
- both `resident-cache-win32-gate` and `windows-native-build-toolchain`
  are present in .github/workflows/dev-ci.yml
- `bun scripts/check-workflow-yaml.ts` parses all three workflows
The @gajae-code/utils import in
packages/coding-agent/test/session-manager-resume-readonly.test.ts was a
single line listing five symbols, which exceeds biome's configured
lineWidth of 120. Biome's formatter therefore reported the file as
unformatted, which failed both `bun run check:tools` at the repo root and
`bun --cwd=packages/coding-agent run check`.

Expand the import to the multi-line form biome expects. The change is
whitespace-only; the imported symbols and their semantics are unchanged.
…vel import

AGENTS.md:66 forbids inline and dynamic type imports, but
measureFsyncCalls used `require("node:fs") as typeof import("node:fs")`.
The violation went unflagged only because the root check:tools tsconfig
scope does not cover packages/coding-agent/scripts.

The existing top-level `fs` binding is `node:fs/promises`, not `node:fs`,
so it cannot supply the type. Add a dedicated top-level
`import type * as nodeFs from "node:fs"` and annotate the cast with
`typeof nodeFs`. A type-only namespace import is sufficient here and is
what biome's useImportType rule requires; it was verified to compile
against packages/coding-agent/tsconfig.json.

createRequire is retained deliberately: syncBuiltinESMExports() reads back
the mutable CJS module object, and the ESM namespace object is not a
substitute for swapping fsyncSync. Only the type annotation changed, so
bench runtime behavior is unaffected.
…foreign session destinations transactionally

Cross-workspace managed session resume was broken by two defects on the
same path.

First, managedStoreFromContext unconditionally forwarded
securityContext.retainedAuthority with authorityBaseDir set to the
candidate directory. For a foreign directory the retained constructor
computes path.relative(authorityBaseDir, baseDir), which is always "",
so snapshotManagedTree("") reported the identity of the directory the
descriptor was actually retained for. #subtreeRoot was frozen with the
original directory's dev/ino but the candidate's canonicalPath, and the
following #assertBound threw "Managed descendant root binding changed".
The retained authority is now forwarded only when the requested
directory is the context's own sessionDir.

Second, setSessionFile only reassigned #sessionFile and session
metadata. destination and #managedTranscriptStoreCache still described
the predecessor, so the first persisted write reached
#managedTranscriptStore and threw "Managed transcript escaped its
session directory". Fixing the pre-flight alone would not have produced
a usable session, so setSessionFile now prepares a managed destination
transition for a foreign candidate and adopts it atomically immediately
before writeTerminalBreadcrumb, with rollback wired into the existing
commit catch blocks.

Security posture is unchanged and in fact stronger. A foreign candidate
takes the non-retained constructor path, which applies managedRelativePath
containment against this manager's own root, assertManagedDirectoryRoot
re-verification, full ancestor-chain owner-only ACL verification via
ensureManagedDirectory, and a fresh dev/ino identity binding; on Linux it
opens a new RecoveryFsRoot and rejects it unless the descriptor identity
matches the pre-open lstat. verifyRootSecurity then takes the fd-bound
verifyOwnerOnlyDirectory branch. Because the rebound context carries
retainedAuthority: undefined, every store later derived from it repeats
that full chain.

File-descriptor lifetime is now explicit instead of finalizer-dependent.
ManagedSessionDescendantStore.close() releases only an authority the
store itself opened, gated by #ownsAuthority and made idempotent by
#closed; borrowed and retained authorities are never closed. A
SessionManager holds at most one owned derived authority, superseded on
rebind and released in close() before the pending persist error is
rethrown.

this.sessionDir is deliberately not rebound. dropSession derives its
managed authorization from resolveManagedSessionRoot(this.sessionDir,
this.cwd); rebinding sessionDir while cwd stayed put would break that
equality and silently drop dropSession into the unbounded
deleteSessionWithArtifacts branch, losing the containment check and the
tombstone. #managedTranscriptStore reads destination.directory, so
rebinding the destination alone is necessary and sufficient. The
accepted consequence is that fork() after a cross-workspace adoption
fails closed, which is pinned by a regression test.

Five of the six managedStoreFromContext call sites pass their own
context's directory and are provably unaffected; the existing
"Managed descendant root binding changed" assertion in
session-manager-resume-readonly.test.ts, which reaches the helper
through the same-directory retained path, still passes unchanged.

Adds four regressions to session-resident-transition-seam.test.ts:
cross-workspace adopt/append/flush/reopen, derived-authority release and
full rollback on a failed switch (Linux-only, asserting exactly one
RecoveryFsRoot.close()), an outside-the-managed-root containment
negative control, and the post-adoption fork fail-closed consequence.

Red-team ultragoal-redteam-resident-cache.test.ts now passes 9/9 with C2
asserting the resident-cache trust error it was written for, without any
edit to C2.
… the destination

In the explicit-destination branch of copyArtifactsForFork,
destinationSnapshot was assigned only after fs.promises.cp() resolved and
the destination snapshot succeeded. The cleanup in the catch was gated on
that variable, so a mid-copy failure (EIO, ENOSPC, EACCES on a nested
entry) left it undefined, skipped cleanup entirely, and orphaned a
partially populated directory at finalDestinationDir. Because the
pre-copy preflight rejects an existing destination with
"destination_conflict", the orphan then poisoned every retry of the same
fork. copyArtifactsForFork throws before returning a publication record,
so fork()'s #cleanupForkArtifactPublication never saw it either.

The copy is now staged into a directory this operation creates itself:
.{name}.{uuid}.fork-staging, mode 0o700, in the same parent as the final
destination. Its root identity is captured while it is still empty, so
deletion authority is established before any content exists rather than
inferred from post-failure state. A post-failure snapshot could not make
that distinction: it would incorporate anything an attacker placed at the
path between the preflight and the failure, and exactRemoveDirectoryTree
validates against the snapshot the caller supplies, so it cannot reject
content the caller already blessed. Quarantine-rename-then-remove at the
final name was also rejected, because the rename is itself
pathname-authorized and would mutate a possibly-foreign object before
discovering it is foreign; that relocates the gap instead of closing it.

Manifest and source-stability checks now run against the staging tree
before anything is published, and publication is a no-replace rename via
renameNoReplacePath classified through classifyNativePublishOutcome. A
directory that appears at the final name after the preflight yields
destination_exists, which is mapped to the pre-existing
"destination_conflict" error so behavior is unchanged for callers, and
that foreign directory is never renamed, quarantined or deleted. Since
the copy never touches the final name, a failure can no longer poison a
retry even if staging cleanup itself fails.

Cleanup goes through removeOwnedForkStaging, which re-proves the staging
root's dev/ino against the pre-copy capture before removing anything.
Only a verified not_found means there is nothing to clean; identity
mismatch, io_error, any other code, and a thrown snapshot call all
escalate as "Failed to clean up explicit fork artifacts: <code>" with the
original failure attached as cause, so a cleanup failure never masks the
original error. cleanup_pending with a retained recovery path is the
authorized POSIX <name>.removing quarantine and rethrows the original
error unchanged.

Adds four regressions to test/session-manager/session-id.test.ts: a
mid-copy cp failure proving the original error propagates with no live
destination and a working retry, an adversarial race control proving a
foreign directory at the final name survives byte-for-byte, a
cleanup-failure test proving the cause chain, and a verified-absence test
proving not_found is the only code treated as nothing-to-clean. The
residue assertions permit the authorized <name>.removing quarantine,
which is the documented POSIX behavior of the path-bound native remove,
while still forbidding any live undetached staging root.
…llback is impossible

The transactional cross-workspace destination rebind closed the previously
owned managed authority inside adopt(). rollback() then restored that same
store into both this.#ownedManagedAuthority and
this.#managedTranscriptStoreCache, so a SECOND cross-workspace
setSessionFile that failed after adoption handed the manager back a store
whose RecoveryFsRoot had already been closed. Every later managed
operation would then run against a released descriptor.

Adoption no longer closes the superseded authority. A new settle() step
runs only once the commit block has completed and rollback is no longer
possible, and releases the superseded authority there. The "at most one
owned authority per manager" invariant is preserved, because settle() runs
on every successful switch, and close() still releases whatever remains
owned when the manager shuts down.

Found by terminal-critic review of the B1 change. No new regression test
accompanies this commit: the available fault injection (poisoning the
resident-cache root) fails before adopt() is reached, so it exercises the
dispose() path rather than the post-adoption rollback path, and a test
using it would pass with or without the defect. Rather than ship a
non-discriminating test that would give false assurance, the invariant is
documented at the call sites. The existing B1 suite and the resident-cache
red-team suite remain green (36 pass / 0 fail).
@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

Blocker resolution map — pushed head 2a186ada2efdba6dc6b7cad4878b054d968ba944

Repairing review 4791778964 (REQUEST_CHANGES @ 1439fd109). Reviewed head had not moved when repair began; 1439fd109 remains an ancestor of the pushed head. Fast-forward push to the existing branch, no force, no second PR, not merged.

Item Root cause Fix location Regression test
B1 managedStoreFromContext forwarded a retained fd authority for a foreign directory, so snapshotManagedTree("") reported the original inode and #assertBound threw. Separately, setSessionFile never rebound destination, so even a passing pre-flight hit the transcript-escape guard on the first write. session-manager.ts managedStoreFromContext, setSessionFile, new transition members, close(); managed-session-storage.ts close() session-resident-transition-seam.test.ts B1-T1/T2/T3/T4
B2 Single-line five-symbol @gajae-code/utils import exceeded biome lineWidth: 120 session-manager-resume-readonly.test.ts:23 none — check:tools / package check are the gates
S1 Inline typeof import("node:fs") violated AGENTS.md:66 resident-memory-bench.ts imports + line 782 none — check:types is the gate
S2 destinationSnapshot was assigned only after cp() resolved, so a mid-copy failure skipped cleanup and poisoned retries with destination_conflict session-manager.ts copyArtifactsForFork explicit branch + removeOwnedForkStaging session-manager/session-id.test.ts S2-T1/T2/T3/T4

Security design preserved

B1 does not weaken the managed boundary — a foreign candidate now gets more verification, not less: managedRelativePath containment against this manager's own root, assertManagedDirectoryRoot re-verification, full ancestor-chain owner-only ACL via ensureManagedDirectory, a fresh dev/ino binding, and on Linux a new RecoveryFsRoot whose identity is checked against the pre-open lstat. Because the rebound context carries retainedAuthority: undefined, every store later derived from it repeats that chain. this.sessionDir is deliberately not rebound: dropSession derives its managed authorization from it, and rebinding would silently drop it into the unbounded delete branch. B1-T3 pins the containment boundary; B1-T4 pins the fail-closed consequence.

Fd lifetime is now explicit rather than finalizer-dependent: close() releases only an authority the store itself opened (#ownsAuthority, idempotent via #closed), a manager holds at most one owned authority, and it is released in close() before the pending persist error is rethrown.

S2 establishes deletion authority before mutation. The copy stages into a 0o700 directory this operation creates under an unguessable name, whose root identity is captured while empty, then publishes with a no-replace rename. A post-failure snapshot was rejected because it cannot distinguish our partial tree from injected content; quarantine-rename-at-the-final-name was rejected because the rename is itself pathname-authorized and would mutate a possibly-foreign object before discovering it is foreign. A directory appearing at the final name after the preflight is preserved byte-for-byte and never renamed, quarantined or deleted — asserted by a dedicated race control (S2-T2). Cleanup re-proves root identity first; only a verified not_found skips it, and every other outcome escalates with the original error as cause.

Verification at this head

Gate Result
bun --cwd=packages/natives run build exit 0
bun run build exit 0
bun run check:tools exit 0 — B2 blocker cleared
bun --cwd=packages/coding-agent run check exit 0
bun --cwd=packages/coding-agent run check:types exit 0 — S1 compile proof
bun run check:public-sync exit 0
ci-dev-affected.ts --dry-run (pinned base) exit 0, selects check:@gajae-code/coding-agent
41-pass set 41 pass / 0 fail
70-pass set (--timeout 60000) 74 pass / 0 fail (70 baseline + 4 new B1 tests)
red-team ultragoal-redteam-resident-cache 9 pass / 0 fail — was 8/1; C2 flipped green and the file is byte-for-byte unedited
test/session-manager/ 247 pass / 5 skip / 2 fail — both failures pre-existing, see below
replay (--timeout 60000) 21 pass / 0 fail
per-commit file allowlist / clean tree exact / clean

Pre-existing failures (not attributable to this PR). rejects an artifact identity mismatch during retained tree fsync and removes published fork artifacts when transcript publication fails both fail with Failed to clean up fork publication: cleanup_pending. Proven pre-existing twice: (a) stashing all repair-file changes reproduces both, and (b) an isolated worktree at the merge commit with zero repair commits gives 12 pass / 2 fail on the same two tests. Both predate this work at 1439fd109. Not waived — documented.

On .removing residue. The S2 residue assertions permit the authorized <name>.removing quarantine while still forbidding any live undetached staging root. This is not a relaxation: exact_remove_directory_tree (path_identity.rs:3640-3707) always detaches to <name>.removing and returns cleanup_pending on POSIX, because POSIX cannot bind the final unlink to the verified root descriptor. The plan's own cleanup contract classifies that as authorized-pending. Using fs.rmSync instead would have violated the compare-before-delete design this review explicitly asked to preserve.

Extra fix from self-review. A terminal-critic pass caught a latent use-after-close in the new B1 code: adopt() closed the superseded authority while rollback() restored that same store, so a second cross-workspace switch failing after adoption would restore a released descriptor. Fixed in 2a186ada2 by deferring the release to a settle() step that runs only once rollback is impossible. Disclosed honestly: a regression test for that path was written and then removed after a controlled experiment showed it passed identically with the buggy code restored — the available fault injection throws before adopt(), so it exercises dispose() rather than post-adoption rollback and could not discriminate. Rather than ship false assurance, the invariant is documented at the call sites and left as a follow-up.

dev integration

Merged origin/dev at pinned fbb5670709a1b1e04abddd1cc4827143fb63b876. .github/workflows/dev-ci.yml conflicted in two env blocks and was resolved by union — this PR's expanded CI_DEV_WINDOWS_DOCTOR_REQUIRED plus dev's CI_DEV_WINDOWS_NATIVE_TOOLCHAIN_*; neither side's coverage dropped. docs-index.generated.ts was regenerated via bun run generate-docs-index, not hand-merged. Verified after merge: getResidentCacheRootDir present in dirs.ts, both resident-cache-win32-gate and windows-native-build-toolchain present in the workflow, and bun scripts/check-workflow-yaml.ts parses all three workflows.

Deliberately untouched

The useLiteralKeys info at provider-auth-health.redteam.test.ts:179 is pre-existing on dev (which has since modified that file) and is an info, not an error — outside the B1/B2/S1/S2 scope, as the review noted. Observations O2-O6 are likewise out of scope; O1 is addressed by the exact-head CI run now in flight.

Not merging — dev is still pending on its own exact head.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a186ada2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +798 to +801
descriptor = fs.openSync(
blobPath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
BLOB_FILE_MODE,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Revalidate the instance directory before blob creation

If another process renames an adopted instance directory and replaces it with a symlink, O_NOFOLLOW protects only the final hash component, so this pathname-based open follows the replaced parent and writes resident transcript data into the symlink target without raising ResidentCacheTrustError. Keep an authority for the verified instance directory and perform blob opens relative to it, or re-prove the instance path identity immediately before each operation.

Useful? React with 👍 / 👎.

Comment on lines +378 to +383
function removeResidentCacheTreeNoFollow(pathname: string): void {
const stat = fs.lstatSync(pathname);
if (stat.isDirectory() && !stat.isSymbolicLink()) {
for (const entry of fs.readdirSync(pathname)) {
removeResidentCacheTreeNoFollow(path.join(pathname, entry));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make recursive cache deletion descriptor-bound

During disposal or stale-instance GC, a concurrent process can replace a directory with a symlink after this lstatSync but before readdirSync; the subsequent pathname traversal then follows the symlink and recursively removes entries outside the cache tree. Quarantine alone does not prevent mutation inside the quarantined directory, so traversal and removal need to remain bound to verified directory descriptors or an exact native snapshot.

Useful? React with 👍 / 👎.

prepared.dispose();
throw error;
}
managedTransition?.settle();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Settle the managed destination only after the rewrite

When switching to a new managed session path, this settles the destination and closes the previous owned authority before #rewriteFile() runs. If that rewrite fails, the outer catch calls dispose(), which is a no-op after adoption, so an AgentSession rollback restores the old session metadata but leaves the manager bound to the new directory; subsequent managed transcript operations then reject the restored old path as escaped. Keep the transition rollback-capable until all fallible post-adoption work completes.

Useful? React with 👍 / 👎.

Comment on lines +557 to +558
for (let entry = entries.readSync(); entry !== null; entry = entries.readSync()) {
if (examined >= maxDirectories || Date.now() - startedAt >= maxDurationMs) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Advance GC beyond the first directory batch

With more than 64 matching instance directories, every sweep opens a fresh iterator and returns after examining the same first 64 entries. If those entries are live or otherwise unreapable, stale instances later in the directory are never visited despite future sessions repeatedly scheduling sweeps, causing permanent cache accumulation. Persist or randomize a cursor, or scan bounded rotating batches.

Useful? React with 👍 / 👎.

…g the primary fork failure

Exact-head CI run 30321719127 failed session-id.test.ts with two genuine
own-head failures: "rejects an artifact identity mismatch during retained
tree fsync" expected identity_mismatch and "removes published fork
artifacts when transcript publication fails" expected io_error, but both
received "Failed to clean up fork publication: cleanup_pending".

These are PR-introduced, not pre-existing. The fork() catch block that
collects cleanupErrors and rethrows them over the primary failure was
added by this PR at 1439fd1; an earlier attribution of these failures to
the base was wrong.

Root cause is error precedence at the cleanup boundary. On POSIX,
removeManagedTree and exact_remove_directory_tree cannot bind the final
unlink to the verified root descriptor, so they detach the tree to a
no-replace <name>.removing name and report cleanup_pending. No live
artifact survives that outcome, so it is a SUCCESSFUL cleanup. The managed
cleanup path routes through removeTreeExpected, which throws on any
non-ok result including cleanup_pending, and fork() then treated that
throw as a cleanup failure and replaced the real error with it. The
explicit branch already tolerated cleanup_pending, and
copyArtifactsForFork's managed branch already had exactly this tolerance,
so the fork publication path was the sole inconsistent site.

Cleanup errors are now filtered through isAuthorizedPendingCleanup before
they may supersede anything. An authorized quarantine preserves and
rethrows the primary error verbatim, with no cause chaining and no
message rewriting. Only an independently real cleanup failure - one that
can leave a live artifact behind - supersedes, and it still attaches the
primary failure as cause so no evidence is lost.

Adds injectManagedTreeRemove to the managed failure-injection helpers
(Linux: RecoveryFsRoot.removeManagedTree; other hosts:
native.exactRemoveDirectoryTree) plus two discriminating regressions: one
asserting a cleanup_pending outcome leaves the io_error primary intact
with no cause and no cleanup wrapper, and one asserting an
identity_mismatch cleanup outcome does supersede while preserving
io_error as cause. Both were verified to fail against the previous
precedence and pass with this change.

session-id.test.ts 20 pass / 0 fail; test/session-manager/ 251 pass /
5 skip / 0 fail (was 2 fail); 74-set 74 pass / 0 fail; red-team 9 pass /
0 fail; 41-set 41 pass / 0 fail; package check and check:types exit 0.
@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

CI repair — pushed head f6fb5c812c737c54454d545e191a0ff078907e41

Correction first: I previously called these two failures pre-existing. That was wrong, and the exact-head CI signal was right. Run 30321719127 failed session-id.test.ts with two genuine own-head failures, and git log -L on the offending block confirms the fork() cleanup-collection catch was introduced by this PR at 1439fd109, not by the base. My earlier attribution came from reproducing the failures at the merge commit — which already contained the PR's own commit — so the experiment could not distinguish PR-introduced from base-introduced. Apologies for the noise.

The defect

rejects an artifact identity mismatch during retained tree fsync
  expected: identity_mismatch   received: Failed to clean up fork publication: cleanup_pending
removes published fork artifacts when transcript publication fails
  expected: io_error            received: Failed to clean up fork publication: cleanup_pending

Error precedence at the cleanup boundary. On POSIX, removeManagedTree / exact_remove_directory_tree cannot bind the final unlink to the verified root descriptor, so they detach the tree to a no-replace <name>.removing and report cleanup_pending. No live artifact survives that outcome — it is a successful cleanup. But the managed path routes through removeTreeExpected, which throws on any non-ok result including cleanup_pending, and fork() then treated that throw as a cleanup failure and replaced the real error with it.

Notably the explicit branch already tolerated cleanup_pending, and copyArtifactsForFork's managed branch already had exactly this tolerance at session-manager.ts:6513. The fork publication path was the one inconsistent site.

The fix

Cleanup errors are filtered through a new isAuthorizedPendingCleanup before they may supersede anything:

  • Authorized quarantine → primary error preserved and rethrown verbatim: no cause chaining, no message rewriting.
  • Independently real cleanup failure (one that can leave a live artifact) → supersedes, and still attaches the primary failure as cause so no evidence is lost.

Discriminating tests

Added injectManagedTreeRemove to the managed failure-injection helpers (Linux: RecoveryFsRoot.removeManagedTree; other hosts: native.exactRemoveDirectoryTree), plus two regressions covering both directions:

Test Asserts
preserves the primary fork failure when publication cleanup only reports an authorized POSIX quarantine io_error survives intact; no Failed to clean up wrapper; cause undefined
escalates a real fork publication cleanup failure with the primary failure as its cause identity_mismatch supersedes and cause.message contains io_error

Both were verified to fail against the previous precedence and pass with this change — I reverted the fix and confirmed the quarantine test goes red, so these are genuinely discriminating rather than tautological.

Verification at this head

Suite Result
session-id.test.ts (the CI-failing file) 20 pass / 0 fail
test/session-manager/ (the CI job scope) 251 pass / 5 skip / 0 fail — was 2 fail
74-set (--timeout 60000) 74 pass / 0 fail
red-team resident cache 9 pass / 0 fail
41-set 41 pass / 0 fail
bun --cwd=packages/coding-agent run check / check:types exit 0

Exact-head Dev CI 30323041313 is running at f6fb5c812. Still not merging — dev remains pending on its own head.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f6fb5c812c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -6520,7 +7638,7 @@ export class SessionManager {
// Only tear down the resident blob store on a terminal outcome; a retryable
// close leaves the session live for a genuine retry.
if (!this.#persistWriter) {
this.#disposeResidentTextBlobStore();
this.#releaseResidentTextStore();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Release the managed authority on strict close

When setSessionFile() rebinds a managed manager to a different directory, the manager stores a self-opened RecoveryFsRoot in #ownedManagedAuthority. A terminal flushAndCloseStrict()/closeStrict() call releases the resident store here but, unlike close(), never calls #releaseOwnedManagedAuthority(), so ACP-style strict shutdown leaves that directory descriptor open while the closed manager remains referenced; repeated managed session deletions can accumulate descriptors and eventually exhaust the process limit. Release the owned authority in this terminal branch as well, while retaining it for close_failed_retryable outcomes.

Useful? React with 👍 / 👎.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

MERGE_READY — exact head f6fb5c812c737c54454d545e191a0ff078907e41

Signed attestation resolving review 4791778964 (REQUEST_CHANGES @ 1439fd109). Repair only; not merged, and no merge will be performed from this lane.

Exact-head status

Signal Value
Head f6fb5c812c737c54454d545e191a0ff078907e41
Checks 46 total — 42 success, 4 skipped, 0 pending, 0 failed
Dev CI 30323041313 — success
Public site sync 30323041319 — success
mergeable_state clean
State open, unmerged, sole open PR for research/RAM-RSS-regressions

1439fd109 remains an ancestor of the head — every push was a plain fast-forward, never a force.

Blocker resolution

Item Status Evidence
B1 — cross-workspace setSessionFile retained-authority regression Resolved Red-team 9/9, C2 flipped green and byte-for-byte unedited
B2fmt:ts / check:tools Resolved check:tools exit 0
S1 — inline typeof import("node:fs") Resolved check:types exit 0; no typeof import( remains
S2 — orphaned partial fork artifacts Resolved Owned-staging + no-replace publish; 4 regressions incl. a race control
O1 — no CI at head Resolved This run

Corrected own-head regression evidence

The review's most important outcome was a defect I introduced and initially misattributed. Recording it plainly:

Exact-head run 30321719127 failed session-id.test.ts with two failures — identity_mismatch and io_error each replaced by Failed to clean up fork publication: cleanup_pending. I first reported these as pre-existing. That was wrong. git log -L on the fork() cleanup-collection catch shows it was added by this PR at 1439fd109. My experiment reproduced the failures "at the merge commit" — which already contained the PR's own commit — so it was structurally incapable of distinguishing PR-introduced from base-introduced. The CI signal was correct; my attribution was not.

Root cause was error precedence at the cleanup boundary. On POSIX, removeManagedTree cannot bind the final unlink to the verified root descriptor, so it detaches to <name>.removing and reports cleanup_pendinga successful cleanup with no surviving live artifact. The managed path routes through removeTreeExpected, which throws on any non-ok result, and fork() treated that throw as a cleanup failure and overwrote the primary error. The explicit branch already tolerated cleanup_pending, and copyArtifactsForFork's managed branch had the identical tolerance at session-manager.ts:6513 — the fork publication path was the single inconsistent site.

Fixed in f6fb5c812: an authorized quarantine preserves and rethrows the primary error verbatim (no cause chaining, no message rewriting); only an independently real cleanup failure supersedes, and it still attaches the primary failure as cause.

Two discriminating regressions were added via a new injectManagedTreeRemove seam, verified by reverting the fix and confirming the quarantine test goes red:

Test Asserts
preserves the primary fork failure when publication cleanup only reports an authorized POSIX quarantine io_error intact; no cleanup wrapper; cause undefined
escalates a real fork publication cleanup failure with the primary failure as its cause identity_mismatch supersedes and cause carries io_error

The previously-failing CI job test:packages/coding-agent/test/session-manager/session-id.test.ts is success at this head.

Local verification at the exact head

session-id.test.ts 20/0 · test/session-manager/ 251 pass, 5 skip, 0 fail (was 2 fail) · 74-set 74/0 · red-team 9/0 · 41-set 41/0 · replay 21/0 · build, check:tools, check:types, check:public-sync, affected-plan all exit 0.

Disclosed limitation

The settle() fix in 2a186ada2 (a use-after-close where adopt() closed the authority rollback() restores) ships without a discriminating regression. The available fault injection throws before adopt(), exercising dispose() rather than post-adoption rollback, so the drafted test passed identically against the buggy code. It was deleted rather than shipped as false assurance; the invariant is documented at the call sites and a discriminating seam remains an open follow-up.

Security design preserved

Foreign candidates receive more verification, not less — containment, root re-verification, full-chain owner-only ACL, fresh fd identity binding, and retainedAuthority: undefined so derived stores repeat the chain. this.sessionDir is deliberately not rebound (dropSession derives its authorization from it). S2 establishes deletion authority before mutation and never mutates a possibly-foreign object. Compare-before-delete, quarantine-instead-of-rm -rf, and the Windows disk gate are all intact.

Merge gate

Do not merge yet. dev at b8a169945 run 30323748939 must reach terminal green first. This PR is merge-ready on its own head only; the merge decision stays with the owner.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5347623500

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

root: string,
destination: SessionDestinationInput,
text: string | string[],
): Promise<Extract<Awaited<ReturnType<typeof SessionManager.restoreMemoryGuardCheckpoint>>, { kind: "staged" }>> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Import the explicit memory-guard result type

Replace this ReturnType<> inference with a top-level import of the exported MemoryGuardRestoreResult type and apply Extract to that named type. The repository contract explicitly prohibits ReturnType<>, including in newly added tests.

AGENTS.md reference: AGENTS.md:L65-L67

Useful? React with 👍 / 👎.

@Yeachan-Heo
Yeachan-Heo merged commit 8347ee3 into dev Jul 28, 2026
44 checks passed
Yeachan-Heo pushed a commit that referenced this pull request Jul 28, 2026
All four PRs terminal: #3373 closed as superseded by repair PR #3413
(merge commit 34cd145, confirmed ancestor of dev); #3357, #3344, and
#3293 (via repair PR #3418) merged directly. Final dev head at batch
close: dae1713 (#3418 merge). No outstanding blocker owned by this lane.
Yeachan-Heo pushed a commit that referenced this pull request Jul 28, 2026
Reclassified pending PR #3429 (doc-only receipt) as ci-wait, gated on
Dev CI run 30374478224 (exact dev head dae1713) reaching terminal
status. All source-level repair work is already terminal in dev. This
resident session retires here; next action documented for the
follow-up census to resume.
Yeachan-Heo pushed a commit that referenced this pull request Jul 28, 2026
All four PRs terminal: #3373 closed as superseded by repair PR #3413
(merge commit 34cd145, confirmed ancestor of dev); #3357, #3344, and
#3293 (via repair PR #3418) merged directly. Final dev head at batch
close: dae1713 (#3418 merge). No outstanding blocker owned by this lane.
Yeachan-Heo pushed a commit to probepark/gajae-code that referenced this pull request Jul 28, 2026
Windows CI is red on every PR that trips the windows-dev-doctor gate,
independent of the PR's own diff (Yeachan-Heo#3423, Yeachan-Heo#3422, Yeachan-Heo#3325, and the
Yeachan-Heo#3424/Yeachan-Heo#3425/Yeachan-Heo#3426/Yeachan-Heo#3428 burst). dev:doctor and windows-canonical-path pass,
then the run exits 1 on:

  bun test packages/coding-agent/test/session/resident-cache-win32-gate.windows.test.ts

`bun test <arg>` treats the argument as a path only when it resolves;
otherwise it silently degrades to a test-name *filter*, matches zero files,
and exits 1 — emitting its own remedy:

  note: To treat the "<path>" filter as a path, run "bun test ./<path>"

On Windows runners the bare relative form does not resolve, so the invocation
became a filter matching nothing. The test itself is correct and is
`describe.skipIf(process.platform !== "win32")`-gated; only the invocation is
wrong. Surfaced when Yeachan-Heo#3344 (1439fd1) added both the test and the workflow
line that runs it.

Repair: prefix with `./` so the argument is unambiguously a path on every
platform. Applied to all six workflow invocations rather than only the one
that broke, since the rest carry the identical latent fault — and the failure
mode is silent-wrong rather than loud: a filter matching nothing can pass on
Linux and fail only on Windows.

Regression coverage (scripts/dev-ci-guard-topology.test.ts): asserts every
`bun test` line in dev-ci.yml addresses files by `./`-prefixed path, so a
future Windows step cannot reintroduce the class. Verified non-vacuous —
reverting line 238 alone fails the selftest with the offending line, and
restoring it passes.

scripts/ci-dev-affected.test.ts pinned the old bare-path string; updated to
the `./` form with a note on why the prefix is load-bearing.

Verification on exact dev:
- workflow-contract suites: 100 pass / 0 fail across 3 files
- check-workflow-yaml.ts: all workflows parse
- biome check .: clean across 3226 files
- No behavior change: same files, same filters, same fail-closed
  $LASTEXITCODE handling.
Yeachan-Heo pushed a commit that referenced this pull request Jul 28, 2026
All four PRs terminal: #3373 closed as superseded by repair PR #3413
(merge commit 34cd145, confirmed ancestor of dev); #3357, #3344, and
#3293 (via repair PR #3418) merged directly. Final dev head at batch
close: dae1713 (#3418 merge). No outstanding blocker owned by this lane.
Yeachan-Heo added a commit that referenced this pull request Jul 28, 2026
All four PRs terminal: #3373 closed as superseded by repair PR #3413
(merge commit 34cd145, confirmed ancestor of dev); #3357, #3344, and
#3293 (via repair PR #3418) merged directly. Final dev head at batch
close: dae1713 (#3418 merge). No outstanding blocker owned by this lane.

Co-authored-by: Yeachan-Heo <yeachan-heo@gajae.dev>
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.

1 participant