Skip to content

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

Open
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-16_compaction-fork-recovery
Open

ranxianglei wants to merge 4 commits into
masterfrom
2026-09-16_compaction-fork-recovery

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Summary

Fixes #407 — two pre-existing state-recovery paths could restore stale or incomplete ACP state.

1. Restart immediately after native compaction (lib/state/state.ts)

ensureSessionInitialized set state.lastCompaction from the current history before loading persisted state, then restored persisted nudge anchors/baselines, message refs, and tool-cache state unconditionally. If native compaction completed after the last persist (restart in between), those fields were stale — and updatePerTurnState could never reset them because its trigger compares against an already-current lastCompaction.

Fix: after the load, compare the current completed-compaction boundary with the persisted _persistedLastCompaction. When history is newer, call the existing resetOnCompaction(state) — which resets message refs, all nudge anchors/baselines, and the tool cache while preserving compression blocks and stats (parity with the live-compaction path) — and let the existing end-of-init save persist the corrected state.

2. Fork recovery (lib/state/state.ts + lib/state/rebuild.ts)

  • Parent state was loaded without the child's resolved storageDir, so with a custom storagePath the parent file was never found → inherited blocks lost whenever the copied history lacked replayable compress inputs. Now passes state.storageDir (undefined falls back to the default location — backward compatible).
  • mapForkIds consumed parent byRef keys verbatim; legacy pre-1.1.0 four-digit refs (m0001) never match fork five-digit refs (m00001). New normalizeParentMessageIds() mirrors the own-session load-time migration (byRef rebuilt from byRawId).

Tests

New tests/restart-compaction-fork-recovery.test.ts (5 tests):

  1. restart after newer compaction → transients reset, blocks/stats preserved, corrected boundary persisted
  2. negative control: restart without newer compaction → transients still restored (no over-resetting)
  3. custom-storage fork e2e → parent found under storagePath, block transferred, fork persisted there
  4. legacy 4-digit parent refs (unit via restoreForkCompressionState)
  5. legacy 4-digit parent refs (full-init e2e through ensureSessionInitialized)

Regression validity verified per AGENTS.md §5.7.3: with the lib fixes stashed, tests 1/3/4/5 FAIL and the control passes; with fixes, 5/5 pass.

Full suite: 1268 tests, 0 failures. Typecheck + build clean.

Devlog: devlog/2026-09-16_compaction-fork-recovery/

@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-16_compaction-fork-recovery (1576ed4)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-409 --global

Each push to this PR publishes a new version under the pr-409 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-16_compaction-fork-recovery" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr409.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Received — this is a completed agent PR (ework-agent-pr marker), so I'm verifying rather than redoing work.

Status so far:

  • Branch 2026-09-16_compaction-fork-recovery fetched; 2 commits (419045b fix + f30da03 devlog).
  • Diff is clean and scoped: lib/state/state.ts (+27/-4), lib/state/rebuild.ts (+39), new tests/restart-compaction-fork-recovery.test.ts (370 lines), devlog REQ/WORKLOG. No version field change, no unrelated files.
  • CI on head f30da03: pr-validation ✅, test (24) ✅, build-artifact ✅, test (22) still running.

I'm now checking the code changes against issue #407 (root-cause validation) and will report findings shortly.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Review complete — PR verified, ready for human merge.

Root-cause validation (against #407, per triage rules):

  • Bug 1 confirmed: lib/state/state.ts:320 sets state.lastCompaction from current history before loading persisted state, so the live trigger in updatePerTurnState (state.ts:41, compares findLastCompactionTimestamp > state.lastCompaction) can never fire for an already-completed compaction — stale refs/nudge baselines/tool cache would be restored forever. The fix's placement (after load+restore at state.ts:459-469, before the end-of-init save at state.ts:484) is correct: after the Math.max merge, lastCompaction > persistedBoundary ⇔ history has a newer compaction than what was persisted.
  • Bug 2 confirmed (both halves): parent was loaded without storageDir (loadSessionState already accepted the optional 3rd param — backward compatible, undefined → default location); and mapForkIds (rebuild.ts:202-203) looks up fork refs via state.messageIds.byRef.get(ref) where fork refs are always 5-digit — legacy 4-digit parent keys could never match, silently dropping inherited blocks. normalizeParentMessageIds mirrors the own-session migration (state.ts:432-443), rebuilds byRef from byRawId (authoritative direction), carries byRef-only entries defensively.

Fix semantics checked:

  • resetOnCompaction (lib/state/utils.ts:402) preserves prune.messages + stats by design (Bug 2 comment) — parity with the live-compaction path holds.
  • Legacy persisted state without _persistedLastCompaction → boundary treated as 0 → reset fires if history contains any completed compaction. This is the safe direction (stale refs reset rather than restored); sessions never compacted have lastCompaction = 0 → behavior unchanged. No regression risk identified.

Verification performed on branch 2026-09-16_compaction-fork-recovery:

  • Full suite locally: 1268 passed / 0 failed; tsc --noEmit clean; build clean.
  • Regression validity re-verified independently (§5.7.3): reverted both lib files to master versions → tests 1/3/4/5 fail, negative control (test 2) passes; restored → 5/5 pass. The new tests genuinely catch the bugs.
  • Test file passes §5.6 checklist: imports real source modules (no local reimplementation), names match assertions, buildConfig() complete incl. gc, legacy-ref inputs actually rewritten to 4-digit form (regex-asserted /^m\d{4}$/), precondition sanity assertions make the reset checks meaningful, no tautologies. Fork-copy uses inputless compress parts — exactly the scenario where replay can't reconstruct and only parent transfer works.
  • Diff cleanliness: 5 files only (lib/state/state.ts, lib/state/rebuild.ts, new test file, devlog REQ/WORKLOG). No version bump, no unrelated changes.
  • CI on head f30da03: pr-validation ✅ · test (22) ✅ · test (24) ✅ · build ✅ · build-artifact ✅ · e2e still running (Docker suite).

No issues found that need fixing in this PR. Merge is a human-only operation — please merge when e2e goes green: #409


中文摘要:已独立验证 #407 两个根因均真实存在(compaction 后重启时 lastCompaction 先于持久化状态加载导致重置永不触发;fork 恢复未传 storageDir 且旧版 4 位 ref 无法匹配 5 位 ref),修复逻辑正确且向后兼容,本地 1268 测试全过、无修复时新测试确实失败(回归有效性成立),CI 除 e2e 外全绿——可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Dual-agent review (AGENTS.md §5.3 + §5.6) — completed, findings fixed on-branch

Reviewer 1 (tests) — verdict: APPROVE.

  • Fixed: buildConfig() in the new test file is now type-conformant to PluginConfig (added logLevel, top-level allowSubAgents, qualityGate, messageFilters; removed mis-nested experimental.allowSubAgents — an inherited gap from tests/rebuild.test.ts).
  • Fixed: pinned with a comment why toolParameters.size === 0 is asserted (the cache is transient by design, re-derived each turn via syncToolCache; it was never persisted).
  • Fixed: added a persisted-side-effect reload assertion to the legacy-ref e2e test.
  • Verified: inputs force the exact code paths under test (input-less compress part blocks replay → parent-state transfer path; summary message triggers findLastCompactionTimestamp), and T1/T3/T4/T5 fail against unfixed code while the negative control passes.

Reviewer 2 (lib code) — verdict: APPROVE, one minor finding fixed on-branch:

  • Parent-state lookup now falls back to the default storage location when the custom-dir load misses (state.ts, fork branch). Without it, the "just configured storagePath" transition (child state still at the default location — the scenario warned about ~15 lines above) would degrade fork recovery from transfer to replay. New regression test added for this path and verified to fail without the fallback.
  • Verified clean: persisted format unchanged, own-session migration loop untouched, no as any/assertion hacks, init-only paths (no hot-path cost), Math.max boundary ordering correct, resetOnCompaction preserves blocks/stats as claimed.

Verification: 6/6 new regression tests pass (all bug repros verified to fail pre-fix); full suite 1269/1269; typecheck + build + Prettier clean.

中文摘要:修复了重启落在原生压缩之后导致过期瞬态状态被无条件恢复、以及自定义 storagePath / 旧版 4 位引用下 fork 恢复丢失继承压缩块的问题;双 agent 审查发现的 storagePath 过渡场景父状态回退缺失已直接在分支上补上并加了回归测试,可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Re-verified the updated branch (head 5d12bb4) after the dual-agent review round, plus one small fix I pushed on-branch.

Verification of 5d12bb4 (review-fix commit)

Diff reviewed line-by-line — all claimed changes are present and correct:

  • Fallback fix (lib/state/state.ts, fork branch): parent load is now loadSessionState(parentSessionId, logger, state.storageDir) with a follow-up if (!parent && state.storageDir) parent = await loadSessionState(parentSessionId, logger). Correct and minimal — the fallback only fires when a custom storageDir is resolved, so default-location behavior is untouched, and it covers exactly the "just configured storagePath" transition the warning above describes.
  • Test file: buildConfig() is now type-conformant (logLevel, top-level allowSubAgents, qualityGate, messageFilters added; mis-nested experimental.allowSubAgents removed), the toolParameters.size === 0 assertion carries the pinning comment, the legacy-ref e2e test asserts the persisted-side-effect reload, and the new transition-fallback test is present.

Local runs on 5d12bb4:

  • Full suite: 1269/1269 pass; tsc --noEmit clean.
  • Code/test files Prettier-clean (see one nit below).
  • Regression validity re-checked both ways: against master's lib, tests 1/3/5/6 fail and the negative control passes; against f30da03's lib (has the storageDir pass but not the fallback), test 4 fails while the other five pass — so the new transition test is a genuine regression test for the fallback, confirming the "verified to fail without the fallback" claim.

One nit found → fixed on-branch as 6d94b7a

The two new devlog markdowns (devlog/2026-09-16_compaction-fork-recovery/REQ.md, WORKLOG.md) were not Prettier-formatted — npm run format:check flags them (list indentation, table padding, *em*_em_). CI doesn't run format:check so nothing blocked, but AGENTS.md §5.1 requires formatting consistency, so I ran prettier --write on just those two files and pushed: 6d94b7a style: prettier-format new devlog files. Markdown-only reflow, no content change.

CI

  • 5d12bb4: pr-validation / test(22) / test(24) / build / build-artifact / Docker e2e — all ✅ (the e2e that was still running in my first review has passed).
  • 6d94b7a: all six checks ✅ after my docs-only commit.

No open findings. Branch is ready — merge is a human-only operation per AGENTS.md §5.1.1.2: #409

中文摘要:复核了双 agent 审查后的分支,fallback 修复与全部测试改动均属实并本地验证(含"无 fallback 时新测试必失败"的回归有效性);发现两个新 devlog 文件未过 Prettier(CI 不查格式所以未拦截),已在分支上直接补了格式化提交 6d94b7a,CI 全绿,可以合并。

- state.ts: after loading persisted state, compare the current
  completed-compaction boundary against the persisted boundary. When
  history is newer (process restarted between native compaction and the
  next transform), reset stale transient refs/nudges/tool cache via
  resetOnCompaction while preserving compression blocks and stats, then
  persist the corrected state. updatePerTurnState cannot catch this case
  because lastCompaction was already set from current history before load.
- state.ts: load parent fork state with the child's resolved storageDir so
  custom storagePath configurations find the parent file.
- rebuild.ts: normalize legacy 4-digit parent message refs to the current
  5-digit format before parent-to-fork translation (mapForkIds).
- tests: restart-after-compaction (with negative control), custom-storage
  fork, and legacy-ref fork regression tests (unit + full-init paths).

Fixes #407
Code review minor: parent-state lookup falls back to the default storage
location when the custom-dir load misses, preserving master behavior in
the just-configured-storagePath transition (transfer instead of replay
degradation). New regression test for the transition path, verified to
fail without the fallback.

Test review minors: buildConfig() now type-conformant to PluginConfig
(logLevel, top-level allowSubAgents, qualityGate, messageFilters);
comment pinning the transient tool-cache assertion; persisted-side-effect
reload assertion in the legacy-ref e2e test.
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.

fix: reconcile compaction restart and custom-storage fork recovery

1 participant