Summary
gjc aborts at startup in a long-lived working directory:
[Uncaught Exception] Error: Could not prepare managed session scope (binding_invalid: prepare:binding_publish).
[Uncaught Exception] crash recorded at /Users/probe/.gjc/agent/gjc-crash.log
The message is wrong in a way that actively misleads. The binding is byte-for-byte canonical. What actually failed is a capacity budget, and binding_invalid sends an operator to delete a healthy binding file instead of pruning the directory.
Two separate defects here: the misreporting (1), and the unbounded growth that triggers it (2).
1. Misclassification
The real error is managed_replace_cleanup_receipt_limit_exceeded, thrown at packages/coding-agent/src/session/internal/managed-session-storage.ts:1249-1250 when #reconcileReplacementCleanupReceipts scans more entries than REPLACEMENT_CLEANUP_RECEIPT_SCAN_LIMIT (= MANAGED_ARTIFACT_MAX_FILES, 50,000).
managedScopeErrorCode only recognized content_too_large, so everything else fell through to binding_invalid (managed-session-scope.ts).
There is a second, subtler half. The operator-visible string comes from cause.classification, produced by managedScopeFailureCause, while code comes from managedScopeErrorCode — two different helpers. Fixing only the code path still printed binding_invalid, which is what made this take a while to pin down.
Confirmed by instrumenting the catch at managed-session-scope.ts:1168:
[P4] stage=binding_publish msg=managed_replace_cleanup_receipt_limit_exceeded ctor=Error
Note the existing doc comment on managedScopeErrorCode already warns about exactly this failure mode for content_too_large — the same trap, a different message.
2. Unbounded receipt/placeholder growth
The affected scope for ~/git/Yeachan-Heo/gajae-code:
total entries 52,796
.gjc-exact-unlink-placeholder-* 26,386
.gjc-receipt-remove-* 13,193
actual session transcripts ~2
46 MB
So >99.9% of the directory is cleanup bookkeeping that was never collected. remove_exchange_placeholder in crates/pi-natives/src/path_identity.rs:3096-3127 returns RetainedMismatch or RetainedFailure(_, "cleanup_pending") on the identity-match path and never returns Removed, so each cycle leaves another quarantined placeholder behind.
The 50,000 limit is not the bug — the growth is. Raising the limit would only delay this.
Reproduction (clean, from scratch)
mkdir /tmp/capcwd && cd /tmp/capcwd
gjc -p "hi" # succeeds, creates the scope
# fill the scope directory past the scan limit
cd ~/.gjc/agent/sessions/v2-<digest-for-that-cwd>
python3 -c "
for i in range(50100):
open(f'.gjc-exact-unlink-placeholder-fill-{i:06d}','w').write('x')"
cd /tmp/capcwd && gjc -p "hi" # crashes
Deterministic. Bisected by moving entries out of the real scope: at 26,499 entries still crashed, at 13,283 it started working.
Why this looks intermittent
It only reproduces through the compiled binary, not bun run dev, because the receipt reconciliation path differs. I burned time on that confound; worth knowing before anyone tries to repro from source.
Impact
gjc becomes unusable in the affected directory — every invocation aborts before the session starts. Recovery requires knowing to delete tens of thousands of dotfiles from an internal state directory, and the error text points at the wrong file.
Any long-lived working directory reaches this eventually. Mine took ~2 days of heavy use.
Fix
PR for (1) incoming: both classification helpers share one predicate, so a capacity failure reports capacity_exceeded with its real message instead of a false binding-corruption report. Verified end-to-end through a rebuilt binary — the crash line becomes:
Could not prepare managed session scope (capacity_exceeded: prepare:binding_publish).
(2) is left open deliberately: pruning during startup is the wrong place for an unbounded delete, and raising the limit hides the growth. The placeholder-retention path in path_identity.rs needs its own decision about when a quarantined placeholder becomes collectable.
Environment
gjc/0.12.21, dev at 7858b0ff6
- darwin arm64 (the tree budgets in
recovery_fs.rs are #[cfg(target_os = "linux")], so they are not what fires here — the TypeScript scan limit is)
Summary
gjcaborts at startup in a long-lived working directory:The message is wrong in a way that actively misleads. The binding is byte-for-byte canonical. What actually failed is a capacity budget, and
binding_invalidsends an operator to delete a healthy binding file instead of pruning the directory.Two separate defects here: the misreporting (1), and the unbounded growth that triggers it (2).
1. Misclassification
The real error is
managed_replace_cleanup_receipt_limit_exceeded, thrown atpackages/coding-agent/src/session/internal/managed-session-storage.ts:1249-1250when#reconcileReplacementCleanupReceiptsscans more entries thanREPLACEMENT_CLEANUP_RECEIPT_SCAN_LIMIT(=MANAGED_ARTIFACT_MAX_FILES, 50,000).managedScopeErrorCodeonly recognizedcontent_too_large, so everything else fell through tobinding_invalid(managed-session-scope.ts).There is a second, subtler half. The operator-visible string comes from
cause.classification, produced bymanagedScopeFailureCause, whilecodecomes frommanagedScopeErrorCode— two different helpers. Fixing only the code path still printedbinding_invalid, which is what made this take a while to pin down.Confirmed by instrumenting the catch at
managed-session-scope.ts:1168:Note the existing doc comment on
managedScopeErrorCodealready warns about exactly this failure mode forcontent_too_large— the same trap, a different message.2. Unbounded receipt/placeholder growth
The affected scope for
~/git/Yeachan-Heo/gajae-code:So >99.9% of the directory is cleanup bookkeeping that was never collected.
remove_exchange_placeholderincrates/pi-natives/src/path_identity.rs:3096-3127returnsRetainedMismatchorRetainedFailure(_, "cleanup_pending")on the identity-match path and never returnsRemoved, so each cycle leaves another quarantined placeholder behind.The 50,000 limit is not the bug — the growth is. Raising the limit would only delay this.
Reproduction (clean, from scratch)
Deterministic. Bisected by moving entries out of the real scope: at 26,499 entries still crashed, at 13,283 it started working.
Why this looks intermittent
It only reproduces through the compiled binary, not
bun run dev, because the receipt reconciliation path differs. I burned time on that confound; worth knowing before anyone tries to repro from source.Impact
gjcbecomes unusable in the affected directory — every invocation aborts before the session starts. Recovery requires knowing to delete tens of thousands of dotfiles from an internal state directory, and the error text points at the wrong file.Any long-lived working directory reaches this eventually. Mine took ~2 days of heavy use.
Fix
PR for (1) incoming: both classification helpers share one predicate, so a capacity failure reports
capacity_exceededwith its real message instead of a false binding-corruption report. Verified end-to-end through a rebuilt binary — the crash line becomes:(2) is left open deliberately: pruning during startup is the wrong place for an unbounded delete, and raising the limit hides the growth. The placeholder-retention path in
path_identity.rsneeds its own decision about when a quarantined placeholder becomes collectable.Environment
gjc/0.12.21,devat7858b0ff6recovery_fs.rsare#[cfg(target_os = "linux")], so they are not what fires here — the TypeScript scan limit is)