Skip to content

fix: reconcile compaction restart and custom-storage fork recovery #407

Open

Description

@drexb-ops

Problems

Two pre-existing state-recovery paths can restore stale or incomplete ACP state:

  1. During session initialization, ACP records the newest compaction timestamp from current history before loading persisted state. It then restores persisted nudge/message-ref/tool-cache state without comparing the current history boundary to the persisted boundary. Because lastCompaction is already current, the next transform does not reset those stale transient fields.
  2. Fork recovery loads the parent state from the default storage location instead of the child session's resolved storagePath. It also consumes legacy four-digit parent message refs without normalizing them to the current five-digit format, so parent-to-fork mapping can fail.

Impact

A restart immediately after native compaction can retain stale refs and nudge baselines. A fork using custom storage or legacy state can lose inherited compression blocks when copied history no longer contains replayable compress inputs.

Proposed fix

  • Compare the current completed-compaction boundary with the persisted boundary after state load. When history is newer, reset transient refs/nudges/tool cache while preserving compression blocks and stats, then persist the corrected state.
  • Load parent fork state from the resolved active storage directory. Normalize legacy parent byRef/byRawId aliases before translating IDs.
  • Add restart-after-compaction, custom-storage fork, and legacy-ref fork regression tests.

Related OpenCode V2 migration: #395. Concurrency work: #404.

Activity

  1. ranxianglei commented on Sep 16, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Acknowledged — triaging #407 now.

    Plan (will verify each claim against the code before implementing):

    1. Reproduce/confirm both recovery paths in lib/state/ (compaction-restart reconciliation + fork storage/ref normalization).
    2. Implement the proposed fixes with regression tests for all three scenarios.
    3. Dual-agent review per AGENTS.md §5.3/§5.6, then open a PR linked to this issue.

    Will report findings as soon as verification is done.

  2. ranxianglei commented on Sep 16, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    Both problems verified real, fixed, and covered by regression tests → PR #409

    Verification (before implementing):

    1. Compaction-restart gap — confirmed. In ensureSessionInitialized (lib/state/state.ts), state.lastCompaction = findLastCompactionTimestamp(messages) runs before the persisted load, then nudge anchors/baselines and message refs are restored unconditionally. After a restart between native compaction and the next transform, updatePerTurnState's reset trigger (lastCompactionTimestamp > state.lastCompaction) can never fire because both sides already equal the new boundary — stale transients survive indefinitely.
    2. Fork recovery gaps — confirmed. (a) The parent load at state.ts:348 omitted the child's resolved storageDir, so with a custom storagePath the parent file was never found; (b) mapForkIds (lib/state/rebuild.ts) consumed parent byRef keys verbatim — legacy pre-1.1.0 four-digit refs (m0001) can never match fork five-digit refs (m00001), so translation yields an empty map and inherited blocks are lost whenever the copied history lacks replayable compress inputs.

    Fixes:

    • Post-load reconciliation: compare the current completed-compaction boundary against _persistedLastCompaction; when history is newer, reset transient refs/nudges/tool cache via the existing resetOnCompaction (preserves compression blocks and stats by design — parity with the live-compaction path) and persist the corrected state.
    • Parent fork state now loads from the resolved active storage directory, with a default-location fallback for the "just configured storagePath" transition (found in code review — without it that scenario degraded from transfer to replay).
    • New normalizeParentMessageIds() normalizes legacy four-digit parent byRef/byRawId before translation (mirrors the own-session load-time migration).

    Tests (tests/restart-compaction-fork-recovery.test.ts, 6 tests): restart-after-compaction reset + negative control (no over-resetting), custom-storage fork e2e, storagePath-transition fallback, legacy-ref fork (unit + full-init e2e). Per §5.7.3 discipline, every bug-repro test was verified to fail against unfixed code (stash fixes → fail; restore → pass); the transition test likewise fails without the fallback.

    Quality gates: dual-agent review completed (§5.3 code + §5.6 tests, both APPROVE; findings fixed on-branch — see PR comment); full suite 1269/1269; typecheck/build/Prettier clean; CI on the PR: pr-validation, build, test (Node 22+24) all passing, e2e job running. Devlog: devlog/2026-09-16_compaction-fork-recovery/.

    📎 PR: #409 (merge is a human-only operation per project rules — please merge when satisfied.)

    中文摘要:修复了重启落在原生压缩之后导致过期消息引用/nudge 基线被无条件恢复、以及自定义 storagePath 或旧版 4 位引用下 fork 恢复丢失继承压缩块的问题(含审查发现的存储路径过渡场景父状态回退缺失),已加 6 个回归测试并验证修复前必失败,PR #409 可以合并。

  3. added 2 commits that reference this issue on Sep 16, 2026
    3f0c0bf
    ca1e1ef
  4. ranxianglei commented on Sep 16, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    Status update — PR #409 is now fully green and up-to-date with master.

    While finalizing I noticed master had moved (v1.18.1 release + #403 Windows path fix merged upstream), so I rebased the branch onto the current master tip (06efd39). Along the way an external commit (style: prettier-format new devlog files, by ranxianglei) had been pushed directly to the PR branch — it was preserved via cherry-pick, nothing was clobbered.

    Final state of #409 :

    • Head 1576ed4, rebased onto latest master, no conflicts
    • CI: pr-validation ✅ · build ✅ · build-artifact ✅ · test Node 22 ✅ · test Node 24 ✅ · e2e ✅
    • Local verification after rebase: typecheck clean, 1280/1280 tests pass, build clean
    • GitHub reports mergeable: true, mergeable_state: clean

    Ready for human merge whenever you are (merge stays a human-only operation per project rules).

    中文摘要:PR #409 已 rebase 到最新 master(含 v1.18.1 与 #403),保留了他人直接推到分支上的 devlog 格式化提交,全部 CI(含 e2e)通过、本地 1280/1280 测试通过,状态 clean,等待人工合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions