Skip to content

fix(compaction): stop null persisted tool arguments from killing the turn - #4633

Merged
probepark merged 1 commit into
Yeachan-Heo:devfrom
Dayoooun:fix/null-persisted-tool-arguments
Aug 18, 2026
Merged

fix(compaction): stop null persisted tool arguments from killing the turn#4633
probepark merged 1 commit into
Yeachan-Heo:devfrom
Dayoooun:fix/null-persisted-tool-arguments

Conversation

@Dayoooun

@Dayoooun Dayoooun commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Problem

Reloading a session whose persisted toolCall.arguments is null kills the turn:

TypeError: null is not an object (evaluating 'args.path')
    at toolCallPath (packages/agent/src/compaction/pruning.ts:239)
    at editToolPathGroups (pruning.ts:261)
    at buildAssistantArgumentStalenessIndex (pruning.ts:323)
    at pruneAssistantToolArguments (pruning.ts:624)

The user sees Error: null is not an object (evaluating 'args.path') with a zero-token
assistant turn (stopReason: "error"). The whole conversation stops rather than the one
unusable tool call being skipped.

ToolCall.arguments is declared Record<string, any> — non-nullable — so nothing in the
type system flagged the dereference. The value nevertheless arrives from disk, where an
earlier cold-spill eviction path persisted null in place of the
__gjcColdSpillArguments sentinel.

Scope, measured on a live store

Scanning 1,282 session files under ~/.gjc/agent/sessions:

Sessions carrying "arguments": null 43
Affected toolCall blocks 3,916
Blocks whose entry also carries a matching message.content.N.arguments cold-spill ref 3,916 / 3,916
Referenced blobs still present on disk 3,916 / 3,916

Every null is paired with a live eviction marker, so this is a broken in-line value, not
lost data. Tool names affected: bash (1,853), write (1,087), edit (804), task,
browser, subagent, todo_write, search, goal.

Current eviction code is correct — a probe run confirmed the sentinel survives memory,
disk, and reopen for both long-string and many-small-field argument shapes. This PR is the
missing read-side invariant for sessions already written.

Fix

Route every read of a persisted argument bag through one guard that treats a non-object
payload as absent, instead of scattering ?. at each crash site:

function toolArguments(call: ToolCall): Record<string, any> | undefined {
	const args = call.arguments;
	return typeof args === "object" && args !== null ? args : undefined;
}

Applied to all eight unguarded dereferences in pruning.ts: path/file_path/filePath
extraction, apply_patch header parsing (arguments.input), idempotent-bash key building
(command, cwd), and search target keys (pattern, paths, skip, i, gitignore).

Fixing only the first site is not enough — after guarding toolCallPath, the regression
test failed again inside editToolPathGroups, then normalizedIdempotentBashCommand.

Also guarded /copy (command-controller.ts:401), which read tc.arguments.command with
the identical shape. It has not thrown yet only because the lookup starts from the newest
turn.

Testing

packages/agent/test/pruning-null-arguments.test.ts — three cases: the argument-pruning
pass, the tool-output pruning pass, and a mixed history proving a genuinely stale edit is
still pruned when a null-argument call sits beside it. Reverting the fix reproduces the
exact production string.

Focused suites, all green on this Windows box:

Command Result
bun test packages/agent/test/pruning-*.test.ts packages/agent/test/maintenance-prune-gate.test.ts 105 pass / 0 fail
bun test packages/coding-agent/test/session-compaction-eviction.test.ts 25 pass / 0 fail
tsc --noEmit in packages/agent and packages/coding-agent clean
biome check on changed files clean
bun scripts/verify-gjc-state-writers.ts --fail 0 write sites outside the sanctioned writer
git merge-base --is-ancestor origin/dev HEAD ok

End-to-end against the real store — open all 43 affected sessions, run the pruning pass,
and check rehydration of every cold-spilled argument payload:

sessions with persisted null tool arguments: 43
pruning pass: ok=43 threw=0
cold-spill arguments: rehydrated=5175 stillNull=0

Before the fix the same walk throws on the first affected session. No data loss: 5,175
argument payloads restore from their blobs, zero remain null.

I'd appreciate a CI run on Linux and macOS to confirm.

GJC verdict

Rebased by maintainer onto current dev 2bd7b4a48c (2026-08-18); Dayoooun authorship and
original author date preserved. The previous maintainer approval and merge-approved verdict
targeted superseded head 094ff6989f and are withdrawn. No authenticated approving review
exists for THIS head yet, so per the template this is needs-human pending fresh authorized
non-author review.

gajae.pr-review-verdict.v1 merge-approved sha256:0ea9f8328636d0f81cd97c3ee32e1ffd6d4250928d523d116d2952d743e8e1f2 reviewer:human reviewer-id:probepark evidence:exact-head-650fc184-guard-covers-all-reachable-malformed-shapes-call-result-pairing-preserved

Maintainer rebase + validation evidence (2026-08-18, head 650fc18, base 2bd7b4a)


  • Target branch is dev
  • Tested locally
  • CHANGELOG updated (if user-facing)
  • Verdict above matches the exact PR head, not an earlier commit

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

@Dayoooun

Copy link
Copy Markdown
Contributor Author

Adding the one piece of evidence a reviewer would reasonably challenge first: if
cold-spill rehydration restores these arguments, why guard the reader at all?

Because the pruning pass never sees a rehydrated entry. Cold-spill rehydration is wired
into exactly four surfaces — getEntryForFidelity, getBranchForFidelity,
visitEntriesForExport / getEntriesForExport, and #entryForProviderContext
(rehydrateColdSpillEntry call sites at session-manager.ts:17178, 17197, 17251, 17271, 17287, 17657).

Compaction pruning reads this.sessionManager.getBranch()
(agent-session.ts:14543), and getBranch() materializes resident blobs only:

path.push(cloneSessionEntry(materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache)));

No rehydrateColdSpillEntry on that path. So the pruning pass observes the persisted
null verbatim, which is why the crash is reachable at all and why the guard belongs on
the reader rather than being deferred to rehydration.

The two facts are complementary, not redundant:

  • Guard (this PR) — the pruning pass must not die on a payload shape that exists on
    disk today. Without it, 43 sessions in my store cannot be resumed.
  • Rehydration (already correct) — the arguments themselves are not lost; the
    provider-context and fidelity paths still restore them from the blob. That is what the
    rehydrated=5175 stillNull=0 measurement demonstrates.

I deliberately did not change the eviction writer. A probe across memory → disk → reopen
confirmed current eviction preserves the __gjcColdSpillArguments sentinel for both
long-string and many-small-field argument shapes, so the writer is not the defect; the
nulls are pre-existing data from an earlier path. Widening this PR to "fix eviction"
would be a fix without a reproducer.

One adjacent reader I checked and left alone: compaction/openai.ts:409 already uses
block.arguments?.input, so it is safe. compaction/utils.ts:41-42 also already
null-checks. The eight sites this PR changes were the only unguarded ones in the crash
path, plus /copy, which shares the shape and is one keystroke from the same throw.

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/null-persisted-tool-arguments branch from 19577d3 to 094ff69 Compare August 18, 2026 00:23
Yeachan-Heo
Yeachan-Heo previously approved these changes Aug 18, 2026

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Independent adversarial review at exact head 094ff69 (rebased onto dev 6696988 by maintainer; original commit 19577d3 preserved as author — cherry-pick kept Author: Dayoooun).

Verified:

  • Guard coverage: toolArguments() covers all four persisted-arg readers in pruning.ts (toolCallPath, editToolPathGroups, normalizedIdempotentBashCommand, toolTargetKey) + /copy guard at command-controller.ts:401. Independent sweep found no other unguarded persisted-arguments dereference on the reload/pruning path.
  • Cold-spill interaction: marker.payloads basePath is checked first in rehydrateColdSpillValue, so null-args entries with payload refs rehydrate fully on the provider-context path; null is never mistaken for a spill/prune sentinel; no double-spill; JSON.stringify(null) null-safe. Data preservation confirmed (author's live-store audit: 5175/5175 payloads rehydratable, 0 still-null).
  • Malformed shapes: primitives guarded by the same typeof check; arrays traced through header parsing and target keys without mis-keying.
  • #4625 relationship: complementary (write/rehydrate-side invariant, zero file overlap), not a duplicate; should merge separately after rebase.
  • Validation at this head: pruning-null-arguments 3 pass; pruning suites 105 pass; agent + coding-agent typecheck/biome clean.

Known residual (base-preexisting, outside this diff, tracked for fix-forward): serializeConversation (compaction/utils.ts:149-153) Object.entries on raw args — same class, one step downstream on the same getBranch() path; plus two contained UI-formatting sites (tree-selector #formatToolCall, formatToolArgs). None regress with this PR; PR fully fixes its named turn-fatal defect.

Verdict footer in PR body regenerated for base 6696988...head 094ff69.

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/null-persisted-tool-arguments branch from 094ff69 to 87a5b73 Compare August 18, 2026 01:54
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Maintainer rebase delivered at 87a5b73 (base 44d7b6e, live dev). Exact final three-dot binary digest, canonical git diff --binary --full-index --no-ext-diff 44d7b6e...87a5b73: 7cd914db2c30d19191aaf44f784d39c99dd6ca33dab17b72d12ae3ac5a73d8c5.

Authorship preserved: single commit, Author: Dayoooun <dayoooun@gmail.com>, author date 2026-08-17T21:57:29+09:00 (cherry-pick of 19577d3). Both CHANGELOG adjacencies resolved additively; no sibling entries removed.

Local validation at this head: pruning-null-arguments 3 pass; pruning red-team/staleness/gate suites + maintenance-prune-gate 105 pass; session-compaction-eviction 25 pass; agent + coding-agent biome/tsc clean; verify-gjc-state-writers --fail 0 violations; changelog-history-guard 12 pass; git diff --check clean. Fail-before re-verified structurally on base 44d7b6e (unguarded args.path read in toolCallPath); the author's live-store audit (43 sessions / 3,916 null-args blocks / 5,175 payloads rehydrated, 0 still-null) stands as the full reproduction.

Status: the prior Yeachan-Heo approval targets superseded head 094ff69 only and does NOT satisfy the exact-head contract at 87a5b73. No self-approval. PR body carries exactly one needs-human verdict (reviewer-id:pending) with the current-head digest. New CI runs 32089919311 / 32089918809 are being tracked; results will be posted here.

Requesting a fresh authorized non-author exact-head review at 87a5b73. On approval + green contract/product CI, the verdict flips to merge-approved with the truthful reviewer-id and evidence, then squash-merge into dev.


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

@Yeachan-Heo
Yeachan-Heo requested a review from snowykr August 18, 2026 02:00
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Signed exact-head CI/review status — head 87a5b73, base 44d7b6e:

  • Verdict (exactly one, verified via REST GET /pulls/4633): gajae.pr-review-verdict.v1 needs-human sha256:7cd914db2c30d19191aaf44f784d39c99dd6ca33dab17b72d12ae3ac5a73d8c5 reviewer:human reviewer-id:pending evidence:…. Digest independently recomputed as the canonical three-dot binary diff digest (git diff --binary --full-index --no-ext-diff 44d7b6e...87a5b73 → SHA-256 7cd914db…) and confirmed identical by the contract job's own computation in run 32090656496.
  • PR contract run 32090656496 (post body-edit): digest accepted, base=dev accepted, ancestry accepted, fast gate passed. Sole remaining diagnostic: Verdict needs-human intentionally blocks merge — the truthful gate, since no authenticated approving review exists for THIS head. (Earlier failures 32089919311 and its rerun read a stale event snapshot carrying the withdrawn 2c4295… verdict; the re-trigger via body edit produced the clean run.)
  • Reviews: the only APPROVED review (Yeachan-Heo) has commit_id=094ff6989f… — superseded head; it does not satisfy the exact-head contract and is NOT being reused. No self-approval issued.
  • Dev CI run 32090656343 in progress at this head (prior 32089918809 was cancelled mid-flight and has been superseded).
  • Reviewer requested: @snowykr (authorized non-author).

Next: on fresh authenticated APPROVED review at 87a5b73 + green contract/product CI, the verdict line flips to merge-approved with the truthful reviewer-id and evidence, then squash-merge into dev.


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

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/null-persisted-tool-arguments branch from 87a5b73 to c13bf36 Compare August 18, 2026 03:16
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Maintainer rebase delivered at c13bf36 onto live dev 7265a61 (which now includes #4616 — verified zero product-file overlap; #4616 touched only session-manager.ts session-list read paths, this PR touches pruning.ts / command-controller.ts / tests / changelogs).

  • Exact final three-dot binary digest (canonical git diff --binary --full-index --no-ext-diff 7265a61c...c13bf36): 75b6109e74db687968d0da71774dfe7ff53557cd677194c2065abaf340cdbabc. PR body carries exactly this head/base/digest, one needs-human verdict (reviewer-id:pending), standard footer — verified via REST.
  • Attribution preserved: single commit, Author: Dayoooun <dayoooun@gmail.com>, author date 2026-08-17T21:57:29+09:00. Product delta identical to the original five-file change; all CHANGELOG siblings preserved (fix(session): stop resume listing paying one read syscall per 4 KiB #4616 resume-listing entry, Windows: uncaught EPERM creating .gjc under a non-writable cwd kills every session start #4568 EPERM entry, and this PR's /copy entry coexist).
  • Local validation at c13bf36: pruning-null-arguments 3 pass; pruning red-team/staleness/gate + maintenance-prune-gate 105 pass; changelog-history-guard 12 pass; session-compaction-eviction 25 pass; agent + coding-agent biome/tsc clean; verify-gjc-state-writers --fail 0 violations; git diff --check clean. Fail-before stands from prior evidence (unguarded args.path read on base semantics; author's live-store audit: 43 sessions / 3,916 null-args blocks / 5,175 payloads rehydrated).
  • Stale state withdrawn: every prior approval/verdict/CI run targeted superseded heads (19577d3 / 094ff69 / 87a5b73). Nothing reused; no self-approval.
  • Replacement CI is expected on this push; the two contract jobs should now fail only on the intentional needs-human gate until a fresh review lands.

@snowykr — requesting fresh authorized non-author exact-head review at c13bf36. On your APPROVED review at this head plus green contract/product CI, the verdict flips to merge-approved with reviewer-id=snowykr and the squash-merge into dev follows immediately.


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

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Signed exact-head blocker/status — head c13bf36e71bd958a229867df64f3e2f2dda060c9, base 7265a61c8ed489b6a9461ba7e991198e46c25208.

Blocker 1 — no fresh authorized non-author review exists for this head. The only APPROVED review on record (user Yeachan-Heo, review id 4955837654) has submitted_at = 2026-08-18T00:24:28Z, which is 2 h 50 m before this head commit existed (c13bf36e71 committer date 2026-08-18T03:14:28Z). Its body text explicitly states it was performed "at exact head 094ff69". After the force-push orphaned 094ff698, the reviews API re-anchored the orphaned record to the current head's SHA, but the review was NOT performed on c13bf36e71. It is a stale old-head approval and is NOT being reused for a merge-approved verdict (also: it was submitted by the same account operating this maintainer lane — self-review semantics, doubly invalid). Reviewer @snowykr is requested; the verdict stays needs-human reviewer-id:pending until their review lands.

Blocker 2 — contract gate intentionally red (truthful). "Validate exact-head PR contract" (runs 32094959558, 32094900736) fails with exactly one diagnostic: Verdict needs-human intentionally blocks merge. Digest 75b6109e74db687968d0da71774dfe7ff53557cd677194c2065abaf340cdbabc was accepted by the validator's own recomputation; base=dev, ancestry, and fast gate all pass. This is the correct pre-approval state, not a defect.

Product CI at this head: Dev CI run 32094959573 in progress (gjcs state-gates ×4 pass, affected-plan pass, native-build running). An earlier affected-path aggregate (run 32094900614) recorded native: cancelled mid-flight when the branch was superseded — superseded, not a product failure. Results of the live run will be appended when it completes.

What was delivered this turn (all verified): rebase of the complete five-file delta onto live dev 7265a61c8e (#4616 included; zero product-file overlap — it touches only session-list read paths in session-manager.ts), authorship + author date preserved (Dayoooun <dayoooun@gmail.com>, 2026-08-17T21:57:29+09:00), all CHANGELOG siblings preserved (#4616, #4568, /copy), force-with-lease push of 094ff698 → c13bf36e71 to the existing branch Dayoooun:fix/null-persisted-tool-arguments, PR body rewritten to exactly one truthful verdict (needs-human, digest 75b6109e…, reviewer-id:pending) + standard footer, verified via REST. Local validation at c13bf36e71: pruning-null-arguments 3 pass; pruning suites + maintenance gate 105 pass; changelog-history-guard 12 pass; session-compaction-eviction 25 pass; agent + coding-agent biome/tsc clean; verify-gjc-state-writers --fail 0 violations; git diff --check clean.

Merge will happen only after: (a) fresh authenticated APPROVED review at c13bf36e71 from an authorized non-author, (b) verdict flipped to merge-approved with that reviewer-id, (c) contract + product CI green.


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

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/null-persisted-tool-arguments branch from c13bf36 to 9ed9fe0 Compare August 18, 2026 04:51
@Yeachan-Heo
Yeachan-Heo dismissed their stale review August 18, 2026 04:56

Dismissed as stale by maintainer: this approval was submitted 2026-08-18T00:24:28Z against head 094ff69 (its body names that head), before any current-head commit existed. GitHub re-anchored the orphaned record to the current head after force-pushes; it never reviewed this code. A genuinely fresh authorized non-author exact-head review is required before any merge-approved verdict. See the signed reconstruction evidence comment.

@Yeachan-Heo
Yeachan-Heo requested a review from probepark August 18, 2026 04:57
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Signed reconstruction evidence — head 9ed9fe0672db67c39705449e7c3519a03d3da769, base 27afb732b3d25632d44176687d5bdd78d3419bb3 (live dev at push time).

Stale-approval disposition (this is the important one). The APPROVED record formerly displayed on this PR (id 4955837654, user Yeachan-Heo) was submitted 2026-08-18T00:24:28Z — its body text names head 094ff6989f, and that timestamp predates every current-head commit (prior reconstruct c13bf36e was created 03:14:28Z). GitHub re-anchored the orphaned review to each new head after the force-pushes, making its commit_id field always equal the current head; that field follows the PR head, not the reviewed commit. It has now been formally DISMISSED via the reviews API with this reasoning. No merge-approved verdict is derived from it. A genuinely fresh authorized non-author exact-head review is required: @probepark @snowykr (both requested).

Reconstruction (this turn). Complete accepted five-file scope cherry-picked onto 27afb732b3; Author: Dayoooun <dayoooun@gmail.com>, author date 2026-08-17T21:57:29+09:00 preserved; committer is the maintainer (rebase metadata only). Files: packages/agent/src/compaction/pruning.ts (+38/−10, the toolArguments() guard across all four persisted-arg readers), packages/coding-agent/src/modes/controllers/command-controller.ts (/copy guard), packages/agent/test/pruning-null-arguments.test.ts (new, 3 tests), both CHANGELOGs (one [Unreleased] entry each).

Overlap inspection (all dev commits since 7265a61): #4616 (session-list read syscalls), #4590 (deep-interview continuation), #4603 (team checkpoint prefixes), #4619/#4636 (ai providers), #4623/#4643/#4645 (sdk/tests), #4585 (darwin nested managed reads pin). Product-file overlap with this PR's scope: none — dev touched session-manager.ts (listing), session-router.ts, team-runtime.ts, deep-interview/SKILL.md, ai/*; this PR touches pruning.ts, command-controller.ts, its test, and changelogs. PR-scope files are byte-identical to the previously accepted head (git diff c13bf36e71 9ed9fe0672 -- <PR files> = 0 lines). CHANGELOG adjacencies resolved additively: the #4645 symlink entry and this /copy entry both present; no sibling removed.

Exact final digest: canonical git diff --binary --full-index --no-ext-diff 27afb732...9ed9fe0 → SHA-256 f53673c06948cd6cccb0c4e94b9578e106c3b2ef94ef8334c53a558befd1dce1. PR body carries exactly one verdict line: needs-human, digest f53673c0…, reviewer-id:pending (verified via REST).

Local validation at 9ed9fe0: pruning-null-arguments 3 pass; pruning red-team/staleness/gate + maintenance-prune-gate 105 pass; changelog-history-guard 12 pass; session-compaction-eviction 25 pass (one earlier 7.6s timeout on a cold run re-ran green in isolation, and green again in the full suite — flake, not a regression; same test passes on bare base); agent + coding-agent biome/tsc clean; verify-gjc-state-writers --fail 0 violations; git diff --check clean. Fail-before evidence unchanged: unguarded args.path read on base semantics throws null is not an object (evaluating 'args.path'); author's live-store audit (43 sessions / 3,916 null-args blocks / 5,175 payloads rehydrated) stands.

CI: this push triggered fresh runs on 9ed9fe0672; prior-head runs are void. Every push invalidates prior review and CI — nothing from heads 19577d3/094ff698/87a5b730/c13bf36e is reused.

On fresh approval at this head + green contract/product CI: verdict flips to merge-approved with the truthful reviewer-id, contract revalidated, squash-merge to dev, merge SHA recorded, fresh-dev bun run build dogfood, post-merge CI reconciled.


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

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Signed current-head status — head 9ed9fe0672db67c39705449e7c3519a03d3da769, base 27afb732b3d25632d44176687d5bdd78d3419bb3.

Product CI: GREEN (Dev CI run 32100764476, completed). All 20 product jobs pass at this exact head: Affected path validation / native-build ✅, Affected path validation / test:packages/agent/test/pruning-null-arguments.test.ts ✅, check:@gajae-code/agent-core ✅, check:@gajae-code/coding-agent ✅, ts-build ✅, cli-smoke ✅, evidence producer ✅, Affected path validation aggregate ✅, Virtual integration validation ✅, gjc-state-gates ×5 ✅. No product failure to fix-forward.

Contract gate: intentionally red, singular cause. PR contract bootstrap (and the standalone Validate exact-head PR contract run 32100764500) fail with exactly one diagnostic: Verdict needs-human intentionally blocks merge. Obtain independent review, update the exact-head verdict to merge-approved, and rerun this check. Digest f53673c06948cd6cccb0c4e94b9578e106c3b2ef94ef8334c53a558befd1dce1, ancestry, exact-head checkout, and writer gate all passed. This is the truthful pre-approval state; contract will be revalidated only after a fresh approval lands.

Review state: the stale re-anchored Yeachan-Heo approval (id 4955837654, submitted 00:24:28Z against old head 094ff69) is formally DISMISSED and will never be reused. Fresh exact-head reviews requested from @probepark and @snowykr. PR body carries exactly one needs-human verdict (reviewer-id:pending, digest f53673c0…), verified via REST.

On a genuinely fresh non-author APPROVED review at this head: I will independently verify its timestamp/body/commit binding and reviewer authority, flip the single verdict line to merge-approved with truthful reviewer evidence, rerun the contract, and squash-merge to dev (product CI is already green at this head), then complete post-merge build + dev CI reconciliation.


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

@probepark probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Independent maintainer review — merge blocked on a one-line contract fix. The fix itself is right.

minor — any in production code

packages/agent/src/compaction/pruning.ts:246 returns Record<string, any>. AGENTS.md: no any unless absolutely necessary. Every consumer already narrows (typeof path === "string", Array.isArray(paths), …), so Record<string, unknown> is a drop-in with no callsite changes and actually enforces those guards.

otherwise

Correct call: this is consumer-side defense against legacy persisted records, and it does not need a producer change — the current cold-spill producer already emits the __gjcColdSpillArguments sentinel (packages/coding-agent/src/session/session-manager.ts:5362-5369) and is covered by existing persisted-reopen tests. The new regression tests genuinely fail on the old direct dereferences.

Swap the any and this is good to land.

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/null-persisted-tool-arguments branch from 9ed9fe0 to b1dbda7 Compare August 18, 2026 10:10
…turn

Sessions written by an earlier cold-spill eviction path persist
`toolCall.arguments` as `null` where the spill sentinel belongs. The
compaction pruning pass dereferenced those arguments unguarded, so
reloading such a session threw

    TypeError: null is not an object (evaluating 'args.path')

which surfaced as a turn-killing provider error rather than a skipped
call. 43 sessions in a live store reproduce it.

`ToolCall.arguments` is typed non-nullable, so the type system never
flagged the gap. Route every read of a persisted argument bag through a
`toolArguments()` guard that treats a non-object payload as absent:
path/file_path/filePath extraction, apply_patch header parsing,
idempotent-bash key building, and search target keys. Also guard the
`/copy` last-bash-command lookup, which had the same shape.

Data is not lost: the eviction marker still names the blob, and
rehydration restores the original arguments (verified across the same 43
sessions, 5,175 cold-spilled argument payloads restored, zero still
null).
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/null-persisted-tool-arguments branch from b1dbda7 to 650fc18 Compare August 18, 2026 10:46
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Signed reconstruction evidence — head 650fc184ca9457b7ddddd408d6fd96efef55fb67, base 2bd7b4a48cd4eb196388744bf0f17f466bd4afa5 (live dev at push time, now includes merged #4625 and #4666).

Review-blocker fix applied (probepark's sole CHANGES_REQUESTED item at 9ed9fe0/b1dbda74): packages/agent/src/compaction/pruning.ts:246 toolArguments() return type changed Record<string, any>Record<string, unknown>. Zero callsite changes (every consumer already narrows), biome + tsc clean. Amended into the single contributor commit.

Reconstruction: complete five-file delta on top of 2bd7b4a48c; Author: Dayoooun <dayoooun@gmail.com>, author date 2026-08-17T21:57:29+09:00, original message preserved. PR-scope files byte-identical to the prior accepted head including the unknown correction (git diff b1dbda74d2 650fc184c -- <PR files> = 0 lines). The #4625 cold-spill changelog sibling and this /copy entry both retained; no sibling removed. Overlap with #4625 verified: it touches session-manager.ts (write-side rehydration invariant), zero overlap with this PR's pruning.ts/command-controller.ts/test/changelogs — genuinely complementary.

Exact final digest: canonical git diff --binary --full-index --no-ext-diff 2bd7b4a4...650fc18 → SHA-256 0ea9f8328636d0f81cd97c3ee32e1ffd6d4250928d523d116d2952d743e8e1f2. PR body carries exactly one verdict line: needs-human, digest 0ea9f832…, reviewer-id:pending.

Local validation at 650fc18: pruning-null-arguments 3 pass; pruning red-team/staleness/gate + maintenance-prune-gate 105 pass; changelog-history-guard 12 pass; session-compaction-eviction 26 pass (includes #4625's new regression — both invariants green together); agent + coding-agent biome/tsc clean; verify-gjc-state-writers --fail 0 violations; git diff --check clean.

Prior state, all superseded by this push: Yeachan-Heo approval DISMISSED (old-head, re-anchored artifact); probepark CHANGES_REQUESTED addressed as above; every CI run and verdict digest from heads 19577d3/094ff698/87a5b730/c13bf36e/9ed9fe06/b1dbda74 is void.

@probepark — the one-line anyunknown swap you requested is in at this head, with both cold-spill invariants (#4625 write-side, now on dev, plus this read-side guard) covered by the 26-test eviction suite. Requesting re-review at 650fc18.

On approval here + green contract/product CI: single verdict flips to merge-approved with truthful reviewer evidence, contract revalidated, squash-merge to dev, fresh-dev build dogfood, terminal evidence posted.


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

@Yeachan-Heo
Yeachan-Heo requested a review from probepark August 18, 2026 10:53

@probepark probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Independent maintainer review at exact head 650fc184approved. Good catch, and the fix is in the right place.

prior finding resolved

toolArguments() now returns Record<string, unknown> instead of Record<string, any>, and every consumer narrows the fields it reads.

the guard is wider than the reported symptom, correctly

I checked whether the fix handles only literal null while a sibling shape still throws — it does not. toolArguments() treats null, missing/undefined, "", "null", and other JSON scalars as "absent" for indexing purposes. Only null/undefined actually threw before; the other scalars were property-access-safe but were still invalid argument bags being indexed as if they were objects. Normalizing all of them through one accessor is the right call — it means the next malformed shape that shows up in a persisted transcript does not reopen this bug.

call/result pairing survives — this was my main worry

A compaction fix that drops a malformed tool call while keeping its result (or the reverse) desynchronizes the transcript and produces a worse failure than the crash. That does not happen here: the call is not dropped and not replaced, the result is not dropped, and the call is merely excluded from target/staleness indexing. Ordinary output pruning may still replace result text with its existing truncation notice, but call and result IDs are untouched. The compacted transcript stays coherent for the next turn.

Legacy records also keep their cold-spill ref, so provider/fidelity materialization restores the original argument object; an unrecoverable ref degrades to the typed malformed-arguments object on current base.

command-controller.ts — checked, and it belongs

/copy cmd had its own direct arguments.command dereference, so the same malformed live tool-call state crashed there too. The one-line optional chain is adjacent hardening rather than scope creep, and it is disclosed in both the body and the coding-agent changelog. It is unpinned by tests (copy-command.test.ts covers default /copy, not command extraction) — worth a line if you touch the file again, not worth blocking.

minor — two of the three tests are bare not.toThrow()

packages/agent/test/pruning-null-arguments.test.ts:78-98. The repo test contract calls these out specifically: a non-throw assertion pins "no crash" but not "correct behavior", so a future fix that stops throwing by silently discarding the whole message would still pass. Assert the returned entry count and call/result-pair preservation instead.

Both still genuinely fail on base (test 1 through buildAssistantArgumentStalenessIndexeditToolPathGroupstoolCallPath; test 2 through buildStalenessIndextoolTargetKeytoolCallPath, which additionally proves the multi-reader defense — partial guarding would expose the bash/search dereferences), so they are real pins, just weakly stated. The third test is the strong one: it proves a malformed neighbor does not suppress pruning of a valid stale edit.

All three hand-build SessionEntry objects. They reproduce the exact inner canonical shape pruning consumes, but none reopens a complete persisted cold-spill record with its entry-level eviction marker — the shape that produced the original report. Not blocking, since the inner shape is what the code reads, but a reopen-based case would be stronger evidence.

sweep

No new any (the ToolCall.arguments: Record<string, any> you see referenced is pre-existing), no ReturnType<>, no inline await import(), no console.* in packages/coding-agent/. Both changelog entries are under ## [Unreleased].

Reviewed by @probepark — method: detached worktree at 650fc184, full read of toolArguments() and every pruning dereference, reachable-shape check against the persistence writer, call/result pairing trace through the pruning path, per-test base-vs-head discrimination. Tests not executed.

gajae.pr-review-verdict.v1 merge-approved sha256:0ea9f8328636d0f81cd97c3ee32e1ffd6d4250928d523d116d2952d743e8e1f2 reviewer:human reviewer-id:probepark evidence:exact-head-650fc184-guard-covers-all-reachable-malformed-shapes-call-result-pairing-preserved

@probepark
probepark merged commit ceb3134 into Yeachan-Heo:dev Aug 18, 2026
55 of 65 checks passed
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Terminal evidence — PR #4633 MERGED to dev. Lane retired.

  • Merge SHA: ceb31349c2d024c75825c819cb530d5060f49380 on dev (squash-merge by probepark at 2026-08-18T14:07:17Z). Commit credits Dayoooun <dayoooun@gmail.com> as author; message carries Reviewed-by: probepark, Verdict: merge-approved sha256:0ea9f832…, Confidence: high, Scope-risk: narrow, Tested: null-argument edit-class indexing / tool-output staleness indexing / stale-edit pruning with a malformed neighbor.
  • Merged delta: exactly the accepted five-file scope — packages/agent/src/compaction/pruning.ts (+38/−10, toolArguments() returning Record<string, unknown> across all four persisted-arg readers), packages/coding-agent/src/modes/controllers/command-controller.ts (/copy guard), packages/agent/test/pruning-null-arguments.test.ts (3 regression tests), both CHANGELOG [Unreleased] entries. 152 insertions / 10 deletions, matching the reviewed digest 0ea9f8328636d0f81cd97c3ee32e1ffd6d4250928d523d116d2952d743e8e1f2.
  • Authorization chain (all independently verified): stale re-anchored Yeachan-Heo approval dismissed with reasoning; probepark's CHANGES_REQUESTED (Record<string, any>Record<string, unknown>) addressed and amended preserving authorship/date; fresh probepark APPROVED review at exact head 650fc184ca (submitted 13:50:01Z, commit_id = 650fc18, write-permission collaborator, body names the head and confirms the guard covers all reachable malformed shapes); PR body verdict updated to merge-approved sha256:0ea9f832… reviewer-id:probepark with truthful evidence; merge followed.
  • Pre-merge validation at 650fc18: Dev CI product jobs 20/20 green (native-build, pruning-null-arguments, both package checks, cli-smoke, evidence producer, virtual integration); contract job failing only on the intentional needs-human gate, which the fresh approval satisfied.
  • Post-merge reconciliation: Dev CI at the merge commit success (run 32146403507, ceb31349c2); Public site sync success (32146403501). Fresh-dev bun run build completed clean (natives + coding-agent binary, 3439 modules bundled). Built dist/gjc --versiongjc/0.14.0; --smoke-test → ok. Merged regression suites re-run at the dev tip: pruning-null-arguments + staleness + maintenance-prune-gate = 62 pass / 0 fail.
  • Linked issues: none exist (PR body carries no Fixes #N); nothing to reconcile.
  • Lane integrity: this worktree (gajae-code-pr-4633-current-head-…) owned only PR fix(compaction): stop null persisted tool arguments from killing the turn #4633 throughout; no other PR/issue touched; no main/release/tag/publish mutations.

Complementary context for the record: merged #4625 (write-side cold-spill object invariant, session-manager.ts) and this PR (read-side consumer guards, pruning.ts) are the two halves of the persisted-null-arguments fix; both are now on dev, and the 26-test session-compaction-eviction suite covers them together.


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

pull Bot pushed a commit to nenyatech-mirror/gajae-code that referenced this pull request Aug 18, 2026
…turn (Yeachan-Heo#4633)

A persisted tool call carrying `arguments: null` crashed compaction pruning on every indexing read, taking down the turn. All argument access now goes through a single `toolArguments()` accessor returning `Record<string, unknown>`, which treats null, undefined, empty strings, and other JSON scalars as absent for indexing purposes.

The malformed call is excluded from target/staleness indexing only — it is not dropped and neither is its result, so call/result pairing and transcript coherence survive compaction. `/copy cmd` carried the same direct dereference and is hardened alongside it.

Reviewed-by: probepark
Verdict: merge-approved sha256:0ea9f8328636d0f81cd97c3ee32e1ffd6d4250928d523d116d2952d743e8e1f2
Confidence: high
Scope-risk: narrow
Tested: null-argument edit-class indexing, tool-output staleness indexing, stale-edit pruning with a malformed neighbor
Not-tested: reopen of a complete persisted cold-spill record; /copy cmd command extraction
(cherry picked from commit ceb3134)
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.

3 participants