From bb23fe4d65887de497063045a1d892e62215ddb1 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Wed, 9 Sep 2026 10:09:52 +0800 Subject: [PATCH 1/2] feat: recover fork compression blocks from parent state when historical compress inputs are stripped Forks copy the parent's message history but often strip the historical compress tool inputs, so the replay-only reconstruction rebuilt zero blocks and the copied raw parent history overflows the fork's context (issue #375). Add a parent-state transfer path for fork init: when a session has a parentID and no fork-local state, load the parent's ACP state, map parent message IDs to the fork's IDs across the copied shared prefix (matched by position + time.created + role, robust to the one-ref shift forks get from being classified as sub-agents), translate the parent's blocks onto the fork's IDs, and save independent fork-local state. Falls back to the existing historical replay when the transfer is not possible. - lib/state/fork-transfer.ts (new): recoverFromParentState + helpers - lib/state/state.ts: fork init tries parent transfer, then replay fallback - lib/state/utils.ts: getForkParentId; isSubAgentSession reuses it - tests/rebuild-parent.test.ts (new): 7 tests incl. ref-shift + fallbacks - devlog/2026-09-09_fork-parent-state-transfer/ Does not mutate the parent; does not inherit nudge cadence / current-turn state. --- .../DESIGN.md | 87 ++++ .../REQ.md | 60 +++ .../WORKLOG.md | 85 ++++ lib/state/fork-transfer.ts | 346 ++++++++++++++ lib/state/state.ts | 23 +- lib/state/utils.ts | 11 +- tests/rebuild-parent.test.ts | 432 ++++++++++++++++++ 7 files changed, 1033 insertions(+), 11 deletions(-) create mode 100644 devlog/2026-09-09_fork-parent-state-transfer/DESIGN.md create mode 100644 devlog/2026-09-09_fork-parent-state-transfer/REQ.md create mode 100644 devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md create mode 100644 lib/state/fork-transfer.ts create mode 100644 tests/rebuild-parent.test.ts diff --git a/devlog/2026-09-09_fork-parent-state-transfer/DESIGN.md b/devlog/2026-09-09_fork-parent-state-transfer/DESIGN.md new file mode 100644 index 00000000..b851d302 --- /dev/null +++ b/devlog/2026-09-09_fork-parent-state-transfer/DESIGN.md @@ -0,0 +1,87 @@ +# DESIGN - Recover compression blocks from parent state when forks omit historical compress inputs + +- Task ID: `2026-09-09_fork-parent-state-transfer` +- Home Repo: `opencode-acp` +- Created: 2026-09-09 +- Status: Accepted + +## 1. Problem Statement + +- **What problem are we solving?** A fork reconstructs its prune state only by replaying completed `compress` tool parts that still carry `state.input`. When the fork copy strips those inputs, no blocks are rebuilt and the copied raw parent history becomes visible, blowing up token usage. +- **Why now?** A production fork hit 20,581+ messages / 925K tokens against a 400K limit with `no_executable_candidates` and no `rebuild: reconstructed` event (issue #375). + +## 2. Goals & Non-Goals + +- **Goals**: + - Recover the parent's compression blocks into a fork that has a `parentID` and no fork-local state. + - Translate block message coverage from parent raw IDs to fork raw IDs across the copied shared prefix. + - Save independent fork-local state without inheriting nudge cadence / current-turn state and without mutating the parent. + - Fall back to the existing historical replay when parent transfer is not possible. +- **Non-Goals**: + - Changing the `isSubAgent` classification of forks (we are robust to the resulting ref shift, but do not alter it). + - A shared/cross-session block store. + +## 3. Current Architecture + +- `ensureSessionInitialized` (lib/state/state.ts) loads the fork's state file; when absent it calls `rebuildCompressionState` (lib/state/rebuild.ts), which replays completed `compress` parts with an object `state.input` and rebuilds blocks from the recorded `startId`/`endId` refs. +- **Pain point**: replay depends entirely on `state.input` surviving the fork copy. Stripped inputs → 0 blocks. + +## 4. Proposed Architecture + +- **Overview**: + +``` +ensureSessionInitialized (fork, no local state) + │ + ├─ getForkParentId(client, sessionId) -> parentId | null + │ + ├─ if parentId: + │ recoverFromParentState(client, state, forkMessages, parentId, logger) + │ 1. loadSessionState(parentId) -> parent state (or null) + │ 2. parent blocks? (>=1) -> else 0 + │ 3. client.session.messages(parentId) -> parent messages (or []) + │ 4. buildSharedPrefixMapping(parent, fork) # position + time.created + role + │ 5. assignMessageRefs(state, fork) # fork refs (idempotent) + │ 6. translate each parent block (raw IDs + startId/endId refs) + │ 7. state.prune.messages = loadPruneMessagesState(translated) + │ return activeBlockCount + │ + ├─ if recovered === 0: + │ rebuildCompressionState(state, forkMessages, config, logger) # existing replay + │ + └─ if recovered > 0: saveSessionState(state) # fork-local, independent +``` + +- **Key components**: + - `recoverFromParentState` (lib/state/fork-transfer.ts) — orchestrates load → map → translate → apply. + - `buildSharedPrefixMapping` — matches parent/fork message lists by position + `time.created` + `role`, stopping at the first mismatch. + - `translateBlock` / `remapBoundaryRef` / `translateByMessageId` — remap a block's coverage and refs onto fork IDs. + - `getForkParentId` (lib/state/utils.ts) — returns the session's `parentID` (string | null). +- **Data flow**: parent state file → in-memory parent blocks → translated fork blocks → fork `prune.messages` → fork state file. +- **API / interface changes**: new exported `recoverFromParentState`; new exported `getForkParentId`; `isSubAgentSession` now delegates to `getForkParentId` (same boolean result). No persisted-format change. + +## 5. Design Decisions & Rationale + +| Decision | Options Considered | Chosen | Why | +|----------|--------------------|--------|-----| +| How to map parent→fork messages | (a) align by ref `mNNNNN`; (b) align by position + `time.created` + role | (b) | Forks have `parentID` ⇒ `isSubAgent` ⇒ `assignMessageRefs` skips the fork's first user message ⇒ fork refs are shifted by one vs the parent. Ref alignment would point blocks at the wrong messages. `time.created`/`role` are preserved by the fork copy; only raw IDs change. | +| Which blocks to transfer | only active; all (active + inactive) | all, then rebuild active sets | Mirrors how the parent's own state is loaded (`loadPruneMessagesState` rebuilds `activeBlockIds`/`activeByAnchorMessageId` from all blocks). Preserves nested/inactive lineage. | +| Blocks whose anchor is outside the shared prefix | fail the whole transfer; skip the block | skip the block | Handles partial forks and parents that continued after the fork; recover what is safely placeable. | +| When to fall back to replay | never; on any failure | on any failure (no parent state / no prefix / no blocks / fetch error / nothing translatable) | Preserves existing behavior as a strict superset. | +| Nudge / current-turn state | copy from parent; do not copy | do not copy | A fork is a fresh session; inheriting cadence would mis-time nudges. | + +## 6. Impact Analysis + +- **Backward compatibility**: none broken. New code path only triggers when `parentID` is set AND no fork-local state exists AND the fork path previously produced 0 blocks. Persisted format unchanged. +- **Performance**: one extra `client.session.messages(parentId)` call per fork init (rare, one-time). Translation is O(blocks + messages). +- **Security**: no new network targets (same `client`); reads the parent's local state file and its messages via the existing SDK client. +- **Dependencies**: none new. + +## 7. Migration Plan + +- **Steps**: none. The feature is additive and self-contained. +- **Feature flags / gradual rollout**: none required; behavior is a strict superset of the current replay path. + +## 8. Open Questions + +- [ ] Should forks stop being classified as sub-agents (which would remove the ref shift at the root)? Out of scope here, but worth a follow-up issue since it also affects the existing replay path's ref resolution. diff --git a/devlog/2026-09-09_fork-parent-state-transfer/REQ.md b/devlog/2026-09-09_fork-parent-state-transfer/REQ.md new file mode 100644 index 00000000..19c2b363 --- /dev/null +++ b/devlog/2026-09-09_fork-parent-state-transfer/REQ.md @@ -0,0 +1,60 @@ +# REQ - Recover compression blocks from parent state when forks omit historical compress inputs + +- Task ID: `2026-09-09_fork-parent-state-transfer` +- Home Repo: `opencode-acp` +- Created: 2026-09-09 +- Status: InProgress +- Priority: P1 +- Owner: ranxianglei +- References: issue #375 (ranxianglei/opencode-acp) + +## 1. Background & Problem Statement + +- **Context**: OpenCode forks receive a new session ID and a copied message history. ACP keeps per-session prune state (compression blocks) in `~/.local/share/opencode/storage/plugin/acp/{sessionId}.json`. A fork has no fork-local state file, so ACP must reconstruct its prune state. +- **Current behavior (symptom)**: `ensureSessionInitialized` (lib/state/state.ts) reconstructs fork state only by replaying completed historical `compress` tool parts whose `state.input` survives (`rebuildCompressionState` in lib/state/rebuild.ts). When the fork copy omits/strips those inputs, **zero** blocks are rebuilt, the copied raw parent history stays visible, and token usage can jump dramatically (observed: 20,581+ messages, 925,103 tokens vs a 400K limit; repeated `reason=no_executable_candidates`, no `rebuild: reconstructed …` event). +- **Expected behavior**: When a session has a `parentID` and no fork-local state, ACP should recover the parent's compression blocks by translating their message coverage onto the fork's message IDs, so the fork prunes the same shared prefix the parent already compressed. +- **Impact**: Forks of long, already-compressed sessions lose all compression and overflow their context window. + +## 2. Reproduction (if applicable) + +- **Environment**: + - Node: 22 / 24 (CI matrix) + - OS/Arch: linux +- **Minimal reproduction steps**: + 1) Parent session compresses a range (a `compress` tool part with `state.input` exists in the parent, and the parent's ACP state file has ≥1 block). + 2) Fork the session. The fork copies the parent's messages but the `compress` part's `state.input` is stripped/omitted. + 3) On first fork init, `rebuildCompressionState` finds no replayable compress input → 0 blocks → the copied raw history is visible. +- **Relevant configuration**: default. + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: must not break existing fork replay (stripped-input case where no parent state exists) or sub-agent behavior. Persisted state format unchanged. + - Performance: one extra `client.session.messages` fetch per fork init (forks are rare; one-time). Acceptable. + - Resource limits: none. + - Must NOT mutate the parent state; must NOT inherit parent nudge cadence / current-turn state. +- **Non-Goals** (explicitly out of scope): + - Fixing the broader sub-agent/fork `isSubAgent` classification (forks are currently treated as sub-agents, which shifts refs by one). This change is *robust to* that shift but does not change it. + - Cross-session block sharing / a shared block store. + +## 4. Acceptance Criteria (must be testable) + +- **Correctness**: + - [x] A fork with a `parentID`, a parent state with ≥1 block, and stripped compress inputs recovers the parent's active blocks with message IDs remapped to the fork's raw IDs. + - [x] Recovery is robust to the fork's ref shift (verified with `isSubAgent=true`): blocks map to the correct fork raw IDs, not shifted refs. + - [x] `startId`/`endId` message refs are remapped to the fork's refs; `bN` block refs are preserved. + - [x] Fork-local state is saved as an independent file; the parent state file is not mutated. +- **Performance / Stability**: + - [x] Falls back to historical replay (returns 0) when: no parent state, no shared message prefix, parent has no blocks, or the parent message fetch fails. + - [x] Parent nudge cadence / current-turn state is NOT copied into the fork. +- **Regression**: + - [x] New test file `tests/rebuild-parent.test.ts` (7 tests) added and passing; full suite green (1084 pass / 0 fail). + +## 5. Proposed Approach (optional) + +- **Affected modules & entry files**: + - `lib/state/fork-transfer.ts` (new) — `recoverFromParentState()`. + - `lib/state/state.ts` — fork init path calls parent-transfer first, then falls back to replay. + - `lib/state/utils.ts` — new `getForkParentId()`; `isSubAgentSession` refactored to reuse it. +- **Risks**: mapping correctness if the fork copy reorders/drops messages (mitigated by position + `time.created` + role matching with a hard stop at the first mismatch); parent message fetch cost (one-time). +- **Rollback strategy**: revert the PR; the fork path reverts to replay-only behavior. No persisted-format migration. diff --git a/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md b/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md new file mode 100644 index 00000000..85e5d807 --- /dev/null +++ b/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md @@ -0,0 +1,85 @@ +# WORKLOG - Recover compression blocks from parent state when forks omit historical compress inputs + +- Task ID: `2026-09-09_fork-parent-state-transfer` +- Home Repo: `opencode-acp` +- Status: InProgress +- Updated: 2026-09-09 09:35 + +## 1. Summary + +- **What was done** (1–3 sentences): Added a parent-state transfer path for fork initialization. When a fork (session with `parentID`) has no fork-local state file, ACP now loads the parent's ACP state, maps the parent's message IDs to the fork's message IDs across the copied shared prefix, translates the parent's compression blocks onto the fork's IDs, and saves independent fork-local state. Falls back to the existing historical replay when the transfer is not possible. +- **Why** (1–3 sentences): Forks strip `compress` tool `state.input`, so replay-only reconstruction yields zero blocks and the copied raw parent history overflows the fork's context. Translating the parent's already-built blocks recovers the compression without depending on `state.input` surviving the copy. +- **Behavior / compatibility changes**: Yes — additive. New recovery path for forks with a `parentID`; existing replay is preserved as the fallback. No persisted-format change. Does not mutate the parent; does not inherit nudge cadence. +- **Risk level**: Medium (new async path touching fork init; mitigated by strict fallback to the existing replay on any failure). + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| `b389f61` | feat: recover fork compression blocks from parent state when historical compress inputs are stripped | + +### Key Files + +- `lib/state/fork-transfer.ts` (new) — `recoverFromParentState()` and its helpers (`buildSharedPrefixMapping`, `translateBlock`, `remapBoundaryRef`, `translateByMessageId`, `extractIdentity`). +- `lib/state/state.ts` — fork init path: resolve `parentId` via `getForkParentId`, try `recoverFromParentState` first, fall back to `rebuildCompressionState`, save if >0. +- `lib/state/utils.ts` — new `getForkParentId()`; `isSubAgentSession` refactored to reuse it (same boolean result). +- `tests/rebuild-parent.test.ts` (new) — 7 unit tests. + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `recoverFromParentState(client, state, forkMessages, parentId, logger): Promise` in `lib/state/fork-transfer.ts`. +- **Key logic explanation** (non-trivial): + - **Mapping**: `buildSharedPrefixMapping` reduces both the parent and fork message lists to `{ id, role, time.created }` identities (skipping synthetic `msg_dcp_*` / `msg_acp_*` messages and empty IDs) and matches them by **position + `time.created` + role**, stopping at the first mismatch. This is deliberately **not** ref-based: forks carry `parentID`, so `isSubAgent` is true and `assignMessageRefs` skips the fork's first user message, shifting the fork's `mNNNNN` refs by one relative to the parent. Matching on `time.created`/`role` (preserved by the fork copy) is robust to that shift. + - **Translation**: each parent block's `directMessageIds` / `effectiveMessageIds` / `directToolIds` / `effectiveToolIds` / `anchorMessageId` / `compressMessageId` are remapped through the mapping; `startId`/`endId` message refs are remapped via `parentByRef → parentRaw → mapping → forkRaw → forkByRawId → forkRef`, while `bN` block refs are preserved (block IDs are kept, so nested lineage stays valid). Blocks whose `anchorMessageId` or `compressMessageId` falls outside the shared prefix are skipped (partial forks / parent continued after fork). + - **State assembly**: translated blocks + translated `byMessageId` are passed to `loadPruneMessagesState`, which rebuilds `activeBlockIds` / `activeByAnchorMessageId` / `nextBlockId` / `nextRunId` exactly as when the parent's own state is loaded. Nudge and current-turn state are intentionally not copied. + - **Tool ids**: `directToolIds` / `effectiveToolIds` hold tool **call** ids (`part.callID`), which are preserved verbatim across a fork copy. They are copied as-is (deduped), NOT run through the message-id mapping — doing so would drop every entry (call ids never equal message ids). + - **Active-only transfer**: `state.prune.messages` is assigned only when at least one **active** block is translatable. If only inactive blocks survive the translation (e.g. the parent continued after the fork), the fork state is left untouched and the caller falls back to replay — otherwise replay would run on top of already-transferred inactive blocks. + - **Boundary refs (best-effort)**: `startId`/`endId` are remapped through the raw-id mapping. When the boundary message's fork raw id has no ref (only the fork's first user message, skipped under the sub-agent classification), the original parent ref is kept. This is metadata only (dedup key, GC-merge boundary, display); core pruning is `byMessageId`-based and unaffected. + - **Fallback**: returns 0 (→ caller runs `rebuildCompressionState`) when the parent state is missing, the parent has no blocks, the parent message fetch fails, the shared prefix is empty, no block is translatable, or no **active** block is translatable. + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +cd opencode-acp && npm run build +node --import tsx --test tests/*.test.ts +node --import tsx --test tests/rebuild-parent.test.ts +npx tsc --noEmit +``` + +### Test Coverage + +- New/modified test files: `tests/rebuild-parent.test.ts` (new, 7 tests). +- Test count: 1084 total, 1084 pass, 0 fail (was 1077 before this change). +- Key scenarios verified: + - Recovery when `state.input` is stripped (the #375 case): 1 block recovered, `anchorMessageId`/`compressMessageId`/`effectiveMessageIds`/`byMessageId` all remapped to fork raw IDs; the compress message is not pruned. Also asserts `startId`/`endId` remap, summary text is preserved, and a tool call id (`tool-1`) survives the transfer. + - Ref-shift robustness: with `isSubAgent=true` (fork's first user message skipped, refs shifted), the same correct raw-ID mapping is produced; `endId` remaps correctly and the best-effort `startId` fallback is pinned. + - Fallbacks: no parent state → 0; no shared prefix (sub-agent messages) → 0; parent has no blocks → 0. + - Independence: fork does not inherit parent nudge tokens; parent state file is not mutated (reloaded: 1 block, nudge token preserved). + - Out-of-prefix skip: parent with two blocks (one anchored inside the copied prefix, one anchored after it) → only the in-prefix block is transferred; the out-of-prefix block is skipped. + +### Results + +- **PASS/FAIL**: PASS. +- **Key logs/data**: `npm run typecheck` clean; `npm run build` → `dist/index.js` 431.73 KB; `npm run test` → `# pass 1084 / # fail 0`. + +## 5. Risk Assessment & Rollback + +- **Risk points**: mapping correctness if a fork copy reorders or drops messages (mitigated by the hard stop at the first position/`time.created`/role mismatch); one extra parent message fetch per fork init. +- **Rollback method**: + - Revert commit(s): `` + - Rollback impact: forks revert to replay-only reconstruction (the pre-change behavior). No data migration. +- **Compatibility notes** (data format, config schema): No — persisted state format and config are unchanged. + +## 6. Lessons Learned (optional) + +- What went well: reusing `loadPruneMessagesState` to rebuild active sets kept the translation consistent with how the parent's own state is loaded. +- What could be improved: the ref shift caused by classifying forks as sub-agents is a latent issue that also affects the existing replay path's ref resolution — see DESIGN §8. +- Reusable conclusions: match fork↔parent messages on `time.created`/`role`/position, never on `mNNNNN` refs. + +## 7. Follow-ups (optional) + +- [ ] Consider whether forks should stop being classified as sub-agents (removes the ref shift at the root; also fixes the existing replay path's ref resolution). diff --git a/lib/state/fork-transfer.ts b/lib/state/fork-transfer.ts new file mode 100644 index 00000000..ed10df1d --- /dev/null +++ b/lib/state/fork-transfer.ts @@ -0,0 +1,346 @@ +/** + * Fork-recovery: transfer ACP compression state from a parent session to a fork. + * + * When OpenCode forks a session, the fork gets a new session ID and a copy of + * the parent's messages with regenerated raw IDs. If that copy strips the + * historical `compress` tool inputs (a common fork-copy behavior), the replay + * path in `rebuild.ts` reconstructs zero blocks and the copied raw parent + * history stays visible → context overflow (issue #375). + * + * This module instead loads the PARENT's persisted ACP state and transfers its + * compression blocks to the fork, translating parent raw message IDs to the + * fork's new raw IDs across the copied shared prefix. + * + * The parent→fork mapping is built by matching the two (filtered) message + * lists on position + `time.created` + role. Those attributes are preserved + * across a fork copy (only raw IDs change), so the mapping is robust to the + * one-ref shift that forks get from being misclassified as sub-agents + * (`isSubAgentSession` → `parentID` set → `assignMessageRefs` skips the first + * user message). A naive ref-to-ref translation would point blocks at the + * wrong messages; this approach does not. + * + * The transfer produces INDEPENDENT fork-local state: + * - parent nudge cadence / current-turn state is NOT inherited; + * - the parent state is never mutated (it is only read); + * - block IDs are preserved, so internal block references (included/consumed/ + * parent) and `bN` boundary refs remain valid. + * + * If the parent state is unavailable, the shared prefix cannot be established, + * or no block is translatable, the function returns 0 so the caller falls back + * to the historical replay path. + */ + +import type { Logger } from "../logger" +import { assignMessageRefs, parseBoundaryId } from "../message-ids" +import { filterMessages } from "../messages/shape" +import { isSyntheticMessage } from "../messages/query" +import { loadSessionState } from "./persistence" +import { loadPruneMessagesState } from "./utils" +import type { CompressionBlock, PrunedMessageEntry, SessionState, WithParts } from "./types" + +interface MessageIdentity { + id: string + role: string + created: number +} + +function extractIdentity(message: WithParts): MessageIdentity | null { + const id = message?.info?.id + if (typeof id !== "string" || id.length === 0) { + return null + } + if (isSyntheticMessage(message)) { + return null + } + const role = message.info.role + const created = message.info.time?.created + if (typeof created !== "number") { + return null + } + return { id, role, created } +} + +/** + * Build a parent-raw-ID → fork-raw-ID mapping over the copied shared prefix. + * + * Both lists are reduced to well-formed, non-synthetic messages and matched by + * position; the match stops at the first position where `time.created` or role + * diverges. A fork copies the parent's prefix in order, so positional + * alignment is correct up to the divergence point. + */ +function buildSharedPrefixMapping( + parentMessages: WithParts[], + forkMessages: WithParts[], +): Map { + const parentIds = parentMessages + .map(extractIdentity) + .filter((x): x is MessageIdentity => x !== null) + const forkIds = forkMessages + .map(extractIdentity) + .filter((x): x is MessageIdentity => x !== null) + + const mapping = new Map() + const limit = Math.min(parentIds.length, forkIds.length) + for (let i = 0; i < limit; i++) { + const p = parentIds[i] + const f = forkIds[i] + if (p.created === f.created && p.role === f.role) { + mapping.set(p.id, f.id) + } else { + break + } + } + return mapping +} + +/** + * Re-map a boundary ref (mNNNNN or bN) from the parent's ref space to the + * fork's ref space. Block refs (bN) are preserved — block IDs are unchanged + * across the transfer. Message refs are resolved through the raw-ID mapping. + * + * Best-effort: when the boundary message's fork raw id has no ref — which only + * happens for the fork's first user message when the fork is classified as a + * sub-agent and `assignMessageRefs` skips it — the original parent ref is kept. + * `startId`/`endId` are metadata (dedup key, GC-merge boundary, display); core + * pruning is `byMessageId`-based and is unaffected by this fallback. + */ +function remapBoundaryRef( + ref: string, + mapping: Map, + forkByRawId: Map, + parentByRef: Map, +): string { + const parsed = parseBoundaryId(ref) + if (!parsed) { + return ref + } + if (parsed.kind === "compressed-block") { + return parsed.ref + } + // kind === "message" + const parentRawId = parentByRef.get(parsed.ref) + if (!parentRawId) { + return ref + } + const forkRawId = mapping.get(parentRawId) + if (!forkRawId) { + return ref + } + const forkRef = forkByRawId.get(forkRawId) + return forkRef ?? ref +} + +/** + * Translate a single parent block to fork raw IDs. Returns null when the block + * cannot be placed in the fork (its anchor or compress message is not in the + * shared prefix). + */ +function translateBlock( + block: CompressionBlock, + mapping: Map, + forkByRawId: Map, + parentByRef: Map, +): CompressionBlock | null { + const anchorForkId = mapping.get(block.anchorMessageId) + if (!anchorForkId) { + return null + } + const compressForkId = mapping.get(block.compressMessageId) + if (!compressForkId) { + return null + } + + const translateList = (ids: string[]): string[] => + ids.map((id) => mapping.get(id)).filter((id): id is string => id !== undefined) + + // directToolIds / effectiveToolIds hold tool CALL ids (part.callID), not message + // ids. Call ids are preserved verbatim across a fork copy, so they must NOT be + // run through the message-id mapping (doing so would drop every entry). + const preserveList = (ids: string[]): string[] => [...new Set(ids)] + + return { + ...block, + anchorMessageId: anchorForkId, + compressMessageId: compressForkId, + directMessageIds: translateList(block.directMessageIds), + effectiveMessageIds: translateList(block.effectiveMessageIds), + directToolIds: preserveList(block.directToolIds), + effectiveToolIds: preserveList(block.effectiveToolIds), + startId: remapBoundaryRef(block.startId, mapping, forkByRawId, parentByRef), + endId: remapBoundaryRef(block.endId, mapping, forkByRawId, parentByRef), + } +} + +/** + * Translate the parent's byMessageId entries to fork raw IDs, keeping only + * entries whose message is in the shared prefix and whose block references + * survived the block translation. + */ +function translateByMessageId( + parentByMessageId: Record, + mapping: Map, + translatedBlockIds: Set, +): Map { + const result = new Map() + for (const [parentMsgId, entry] of Object.entries(parentByMessageId)) { + if (!entry || typeof entry !== "object") { + continue + } + const forkMsgId = mapping.get(parentMsgId) + if (!forkMsgId) { + continue + } + const allBlockIds = (entry.allBlockIds ?? []).filter((id) => translatedBlockIds.has(id)) + const activeBlockIds = (entry.activeBlockIds ?? []).filter((id) => + translatedBlockIds.has(id), + ) + result.set(forkMsgId, { + tokenCount: typeof entry.tokenCount === "number" ? entry.tokenCount : 0, + allBlockIds, + activeBlockIds, + }) + } + return result +} + +/** + * Recover the fork's compression state from the parent's persisted ACP state. + * + * Returns the number of ACTIVE blocks transferred (0 when the transfer is + * unavailable or yields nothing prunable, in which case the caller should fall + * back to the historical replay path). + */ +export async function recoverFromParentState( + client: any, + state: SessionState, + forkMessages: WithParts[], + parentId: string, + logger: Logger, +): Promise { + // 1. Load the parent's persisted ACP state (read-only — never mutated). + const parentState = await loadSessionState(parentId, logger) + if (!parentState) { + logger.info("fork-transfer: no parent state found, falling back to replay", { + parentId, + }) + return 0 + } + + const parentMessagesState = parentState.prune?.messages + const parentBlocks = parentMessagesState?.blocksById + if (!parentBlocks) { + logger.info("fork-transfer: parent state has no blocks, falling back to replay", { + parentId, + }) + return 0 + } + const parentBlockEntries = Object.entries(parentBlocks).filter( + ([id, block]) => + Number.isInteger(Number.parseInt(id, 10)) && Number.parseInt(id, 10) >= 1 && !!block, + ) + if (parentBlockEntries.length === 0) { + logger.info("fork-transfer: parent state has no blocks, falling back to replay", { + parentId, + }) + return 0 + } + + // 2. Fetch the parent's messages to establish the shared-prefix mapping. + let parentMessages: WithParts[] + try { + const response = await client.session.messages({ path: { id: parentId } }) + parentMessages = filterMessages(response?.data || response) + } catch (error: any) { + logger.warn("fork-transfer: failed to fetch parent messages, falling back to replay", { + parentId, + error: error?.message || String(error), + }) + return 0 + } + + // 3. Build the parent→fork mapping over the copied shared prefix. + const mapping = buildSharedPrefixMapping(parentMessages, forkMessages) + if (mapping.size === 0) { + logger.info("fork-transfer: no shared prefix detected, falling back to replay", { + parentId, + }) + return 0 + } + + // 4. Assign fork refs (idempotent) so block boundary refs can be re-mapped. + assignMessageRefs(state, forkMessages) + const forkByRawId = state.messageIds.byRawId + const parentByRef = new Map(Object.entries(parentState.messageIds?.byRef ?? {})) + + // 5. Translate every parent block (active and inactive). + const translatedBlocks = new Map() + let skipped = 0 + for (const [blockIdStr, block] of parentBlockEntries) { + const blockId = Number.parseInt(blockIdStr, 10) + const translated = translateBlock(block, mapping, forkByRawId, parentByRef) + if (translated) { + translatedBlocks.set(blockId, translated) + } else { + skipped++ + } + } + + if (translatedBlocks.size === 0) { + logger.info("fork-transfer: no blocks translatable, falling back to replay", { + parentId, + parentBlockCount: parentBlockEntries.length, + mappingSize: mapping.size, + }) + return 0 + } + + // 6. Only transfer when at least one ACTIVE block is translatable. Inactive + // blocks prune nothing, so if only inactive blocks survive the translation + // (e.g. the parent continued after the fork, so the active blocks' compress + // messages fall outside the copied prefix) we leave the fork's state + // untouched and let the caller fall back to replay — otherwise the replay + // would run on top of already-transferred inactive blocks. + const activeCount = Array.from(translatedBlocks.values()).filter((b) => b.active).length + if (activeCount === 0) { + logger.info("fork-transfer: no active blocks translatable, falling back to replay", { + parentId, + recoveredBlocks: translatedBlocks.size, + skippedBlocks: skipped, + mappingSize: mapping.size, + }) + return 0 + } + + // 7. Apply the translated state to the fork (independent fork-local state). + // loadPruneMessagesState rebuilds activeBlockIds / activeByAnchorMessageId / + // nextBlockId / nextRunId from the blocks, mirroring how the parent state + // is loaded. Nudge cadence / current-turn state are intentionally NOT copied. + const translatedByMessageId = translateByMessageId( + parentMessagesState.byMessageId ?? {}, + mapping, + new Set(translatedBlocks.keys()), + ) + state.prune.messages = loadPruneMessagesState({ + byMessageId: Object.fromEntries(translatedByMessageId), + blocksById: Object.fromEntries( + Array.from(translatedBlocks.entries()).map( + ([id, block]): [string, CompressionBlock] => [String(id), block], + ), + ), + activeBlockIds: [], + activeByAnchorMessageId: {}, + nextBlockId: 1, + nextRunId: 1, + markedForCleanup: [], + }) + + logger.info("fork-transfer: recovered compression state from parent", { + parentId, + recoveredBlocks: translatedBlocks.size, + activeBlocks: activeCount, + skippedBlocks: skipped, + mappingSize: mapping.size, + }) + + return activeCount +} diff --git a/lib/state/state.ts b/lib/state/state.ts index dc449536..26a5e578 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -9,8 +9,9 @@ import { import { loadSessionState, saveSessionState } from "./persistence" import { createModelLimitCatalog } from "./model-limits" import { rebuildCompressionState } from "./rebuild" +import { recoverFromParentState } from "./fork-transfer" import { - isSubAgentSession, + getForkParentId, findLastCompactionTimestamp, countTurns, resetOnCompaction, @@ -266,8 +267,8 @@ export async function ensureSessionInitialized( resetSessionState(state) state.sessionId = sessionId - const isSubAgent = await isSubAgentSession(client, sessionId) - state.isSubAgent = isSubAgent + const parentId = await getForkParentId(client, sessionId) + state.isSubAgent = parentId !== null state.lastCompaction = findLastCompactionTimestamp(messages) state.currentTurn = countTurns(state, messages) @@ -275,12 +276,18 @@ export async function ensureSessionInitialized( const persisted = await loadSessionState(sessionId, logger) if (persisted === null) { - // Fork recovery: no persisted state for this session. If config is - // available, replay historical compress tool invocations to rebuild - // pruning state using the current session's message IDs. + // Fork recovery: no persisted state for this session. Prefer + // parent-state transfer (robust to stripped compress inputs); fall + // back to replaying historical compress invocations. if (config) { - const rebuilt = rebuildCompressionState(state, messages, config, logger) - if (rebuilt > 0) { + let recovered = 0 + if (parentId) { + recovered = await recoverFromParentState(client, state, messages, parentId, logger) + } + if (recovered === 0) { + recovered = rebuildCompressionState(state, messages, config, logger) + } + if (recovered > 0) { await saveSessionState(state, logger) } } diff --git a/lib/state/utils.ts b/lib/state/utils.ts index ecebf5d8..f064a418 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -58,15 +58,20 @@ export function serializePruneMessagesState( } } -export async function isSubAgentSession(client: any, sessionID: string): Promise { +export async function getForkParentId(client: any, sessionID: string): Promise { try { const result = await client.session.get({ path: { id: sessionID } }) - return !!result.data?.parentID + return result.data?.parentID ?? null } catch (error: any) { - return false + return null } } +export async function isSubAgentSession(client: any, sessionID: string): Promise { + const parentId = await getForkParentId(client, sessionID) + return parentId !== null +} + export function findLastCompactionTimestamp(messages: WithParts[]): number { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] diff --git a/tests/rebuild-parent.test.ts b/tests/rebuild-parent.test.ts new file mode 100644 index 00000000..823c96b3 --- /dev/null +++ b/tests/rebuild-parent.test.ts @@ -0,0 +1,432 @@ +import "./test-env" +import assert from "node:assert/strict" +import test from "node:test" +import { recoverFromParentState } from "../lib/state/fork-transfer" +import { rebuildCompressionState } from "../lib/state/rebuild" +import { createSessionState } from "../lib/state/state" +import { saveSessionState, loadSessionState } from "../lib/state/persistence" +import { Logger } from "../lib/logger" +import type { PluginConfig } from "../lib/config" +import type { SessionState, WithParts } from "../lib/state/types" + +const logger = new Logger(false) + +const PARENT_ID = "parent-session-375" +const FORK_ID = "fork-session-375" + +function buildConfig(): PluginConfig { + const base: PluginConfig = { + enabled: true, + autoUpdate: true, + debug: false, + logLevel: "info", + allowSubAgents: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + experimental: { customPrompts: false }, + protectedFilePatterns: [], + compress: { + permission: "allow", + showCompression: false, + summaryBuffer: true, + maxContextLimit: 150000, + minContextLimit: 50000, + nudgeFrequency: 5, + minNudgeContextPercent: 20, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: ["task"], + protectTags: false, + protectUserMessages: false, + maxSummaryLengthHard: 10000, + minCompressRange: 0, + maxVisibleSegments: 3, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, + }, + qualityGate: { enabled: false, algorithm: "rouge-recall-v1", algorithms: {} }, + messageFilters: { enabled: false, filters: {} }, + } + return base +} + +function makeUserMessage(id: string, text: string, created: number, sessionID: string): WithParts { + return { + info: { + id, + sessionID, + role: "user", + agent: "assistant", + time: { created }, + model: { providerID: "test-provider", modelID: "test-model" }, + } as WithParts["info"], + parts: [{ type: "text", text, id: `${id}-p1`, sessionID, messageID: id }], + } +} + +function makeAssistantMessage( + id: string, + parts: any[], + created: number, + sessionID: string, +): WithParts { + return { + info: { + id, + sessionID, + role: "assistant", + agent: "test", + time: { created }, + parentID: "parent-1", + modelID: "test-model", + providerID: "test-provider", + mode: "normal", + path: { cwd: "/", root: "/" }, + summary: false, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as WithParts["info"], + parts, + } +} + +function makeTextPart(text: string): any { + return { type: "text", text } +} + +// A completed non-protected tool part. Its callID is what lands in a block's +// directToolIds/effectiveToolIds (see lib/compress/search.ts), so it lets the +// tests verify tool-id transfer (call ids are preserved verbatim across a fork). +function makeToolPart(callId: string): any { + return { type: "tool", tool: "bash", callID: callId, state: { status: "completed" } } +} + +function makeCompressPart(callId: string, input: any): any { + return { + type: "tool", + tool: "compress", + callID: callId, + state: { + status: "completed", + input, + output: "Compressed messages into [Compressed conversation section].", + }, + } +} + +// A completed compress part whose `state.input` has been stripped (the fork-copy +// scenario from issue #375). Replay cannot reconstruct a block from this. +function makeStrippedCompressPart(callId: string): any { + return { + type: "tool", + tool: "compress", + callID: callId, + state: { + status: "completed", + output: "Compressed messages into [Compressed conversation section].", + }, + } +} + +const COMPRESS_INPUT = { + topic: "Intro chat", + content: [ + { + startId: "m00001", + endId: "m00004", + summary: "User greeted and started a task.", + }, + ], +} + +// A second parent compression over a later range (m00006..m00008) used to verify +// that a block anchored OUTSIDE the fork's copied prefix is skipped. +const COMPRESS_INPUT_2 = { + topic: "Later chat", + content: [ + { + startId: "m00006", + endId: "m00008", + summary: "Later conversation.", + }, + ], +} + +// Parent messages: 4 visible messages + 1 assistant message holding a completed +// compress part (with input) covering m00001..m00004. +function makeParentMessages(): WithParts[] { + return [ + makeUserMessage("p1", "hello", 1000, PARENT_ID), + makeAssistantMessage("p2", [makeTextPart("hi"), makeToolPart("tool-1")], 1001, PARENT_ID), + makeUserMessage("p3", "do a task", 1002, PARENT_ID), + makeAssistantMessage("p4", [makeTextPart("doing it")], 1003, PARENT_ID), + makeAssistantMessage("p5", [makeCompressPart("call-1", COMPRESS_INPUT)], 1004, PARENT_ID), + ] +} + +// Fork messages: identical content / time.created / role, but NEW raw IDs (f*) +// and the compress input is STRIPPED. +function makeForkMessages(): WithParts[] { + return [ + makeUserMessage("f1", "hello", 1000, FORK_ID), + makeAssistantMessage("f2", [makeTextPart("hi"), makeToolPart("tool-1")], 1001, FORK_ID), + makeUserMessage("f3", "do a task", 1002, FORK_ID), + makeAssistantMessage("f4", [makeTextPart("doing it")], 1003, FORK_ID), + makeAssistantMessage("f5", [makeStrippedCompressPart("call-1")], 1004, FORK_ID), + ] +} + +function makeClient(parentMessages: WithParts[]): any { + return { + session: { + messages: async (_opts: any) => ({ data: parentMessages }), + }, + } +} + +// Build + persist a parent session whose state contains one active range block. +async function setupPersistedParent( + overrides: { nudgeTokens?: number } = {}, +): Promise<{ parentMessages: WithParts[]; blockCount: number }> { + const parentState = createSessionState() + parentState.sessionId = PARENT_ID + parentState.isSubAgent = false + if (overrides.nudgeTokens !== undefined) { + parentState.nudges.lastPerMessageNudgeTokens = overrides.nudgeTokens + } + const parentMessages = makeParentMessages() + const rebuilt = rebuildCompressionState(parentState, parentMessages, buildConfig(), logger) + assert.equal(rebuilt, 1, "parent should rebuild exactly 1 block") + await saveSessionState(parentState, logger) + return { parentMessages, blockCount: parentState.prune.messages.blocksById.size } +} + +function freshForkState(isSubAgent: boolean): SessionState { + const state = createSessionState() + state.sessionId = FORK_ID + state.isSubAgent = isSubAgent + return state +} + +test("recovers blocks from parent state when fork compress inputs are stripped", async () => { + const { parentMessages } = await setupPersistedParent() + const client = makeClient(parentMessages) + const forkState = freshForkState(false) + + const recovered = await recoverFromParentState( + client, + forkState, + makeForkMessages(), + PARENT_ID, + logger, + ) + + assert.equal(recovered, 1, "should recover 1 active block") + assert.equal(forkState.prune.messages.blocksById.size, 1) + + const block = forkState.prune.messages.blocksById.get(1)! + assert.equal(block.active, true) + assert.equal(block.anchorMessageId, "f1", "anchor should map to the fork raw ID") + assert.equal(block.compressMessageId, "f5", "compress message should map to the fork raw ID") + assert.deepEqual(block.effectiveMessageIds, ["f1", "f2", "f3", "f4"]) + // No ref shift here (isSubAgent=false): f1→m00001, f4→m00004, so the boundary + // refs remap to the same values in the fork's ref space. + assert.equal(block.startId, "m00001") + assert.equal(block.endId, "m00004") + // Summary text and tool-call ids (preserved verbatim, not message ids) survive. + assert.ok(block.summary.includes("User greeted and started a task.")) + assert.ok(block.effectiveToolIds.includes("tool-1"), "tool call id should be preserved") + + for (const id of ["f1", "f2", "f3", "f4"]) { + const entry = forkState.prune.messages.byMessageId.get(id) + assert.ok(entry, `message ${id} should be in byMessageId`) + assert.ok(entry!.activeBlockIds.includes(1), `message ${id} should have block 1 active`) + } + assert.ok( + !forkState.prune.messages.byMessageId.has("f5"), + "compress message should not be pruned", + ) + assert.equal(forkState.prune.messages.activeByAnchorMessageId.get("f1"), 1) +}) + +test("recovers blocks correctly even when the fork is misclassified as a sub-agent (ref shift)", async () => { + // A real fork has parentID set → isSubAgent=true → assignMessageRefs skips + // the fork's first user message, shifting its refs by one relative to the + // parent. The time.created-based mapping must still point blocks at the + // correct fork raw IDs. + const { parentMessages } = await setupPersistedParent() + const client = makeClient(parentMessages) + const forkState = freshForkState(true) + + const recovered = await recoverFromParentState( + client, + forkState, + makeForkMessages(), + PARENT_ID, + logger, + ) + + assert.equal(recovered, 1, "should recover 1 active block despite the ref shift") + const block = forkState.prune.messages.blocksById.get(1)! + assert.equal( + block.anchorMessageId, + "f1", + "anchor must still map to f1 (raw IDs are shift-robust)", + ) + assert.equal(block.compressMessageId, "f5") + assert.deepEqual(block.effectiveMessageIds, ["f1", "f2", "f3", "f4"]) + // endId remaps correctly: parent m00004 → p4 → f4, and f4 has fork ref m00003. + assert.equal(block.endId, "m00003") + // startId is best-effort: the start boundary is f1, the fork's first user + // message, which `assignMessageRefs` skips (no ref). remapBoundaryRef keeps + // the parent ref "m00001" — metadata only; pruning is byMessageId-based. + assert.equal(block.startId, "m00001") + for (const id of ["f1", "f2", "f3", "f4"]) { + const entry = forkState.prune.messages.byMessageId.get(id) + assert.ok(entry, `message ${id} should be in byMessageId`) + assert.ok(entry!.activeBlockIds.includes(1)) + } +}) + +test("returns 0 and leaves state untouched when no parent state exists", async () => { + // No parent state persisted → loadSessionState returns null → fallback. + const client = makeClient(makeParentMessages()) + const forkState = freshForkState(false) + + const recovered = await recoverFromParentState( + client, + forkState, + makeForkMessages(), + "no-such-parent", + logger, + ) + + assert.equal(recovered, 0) + assert.equal(forkState.prune.messages.blocksById.size, 0) + assert.equal(forkState.prune.messages.byMessageId.size, 0) +}) + +test("returns 0 when there is no shared prefix (sub-agent, not a fork copy)", async () => { + await setupPersistedParent() + // Sub-agent messages: same shape but different time.created → no positional + // match with the parent's messages. + const subAgentMessages: WithParts[] = [ + makeUserMessage("s1", "unrelated prompt", 9000, FORK_ID), + makeAssistantMessage("s2", [makeTextPart("unrelated reply")], 9001, FORK_ID), + ] + const client = makeClient(makeParentMessages()) + const forkState = freshForkState(true) + + const recovered = await recoverFromParentState( + client, + forkState, + subAgentMessages, + PARENT_ID, + logger, + ) + + assert.equal(recovered, 0, "no shared prefix → no transfer") + assert.equal(forkState.prune.messages.blocksById.size, 0) +}) + +test("returns 0 when the parent state has no blocks", async () => { + // Persist a parent state with an empty prune state (no compress happened). + const parentState = createSessionState() + parentState.sessionId = PARENT_ID + parentState.isSubAgent = false + await saveSessionState(parentState, logger) + + const client = makeClient(makeParentMessages()) + const forkState = freshForkState(false) + + const recovered = await recoverFromParentState( + client, + forkState, + makeForkMessages(), + PARENT_ID, + logger, + ) + + assert.equal(recovered, 0) + assert.equal(forkState.prune.messages.blocksById.size, 0) +}) + +test("does not inherit parent nudge state and does not mutate the parent", async () => { + await setupPersistedParent({ nudgeTokens: 424242 }) + const client = makeClient(makeParentMessages()) + const forkState = freshForkState(false) + + await recoverFromParentState(client, forkState, makeForkMessages(), PARENT_ID, logger) + + // Fork nudge cadence must stay fresh (not copied from the parent). + assert.equal(forkState.nudges.lastPerMessageNudgeTokens, undefined) + assert.equal(forkState.nudges.lastNudgeShownTokens, undefined) + + // Parent state on disk is unchanged (still 1 block, nudge token preserved). + const reloaded = await loadSessionState(PARENT_ID, logger) + assert.ok(reloaded, "parent state should still be loadable") + assert.equal(Object.keys(reloaded!.prune.messages!.blocksById).length, 1) + assert.equal(reloaded!.nudges.lastPerMessageNudgeTokens, 424242) +}) + +test("transfers only blocks whose coverage lies within the shared prefix", async () => { + // The parent compressed twice: block 1 over m00001..m00004 (anchor p1) and + // block 2 over m00006..m00008 (anchor p6). The fork was created when the + // parent had 5 messages, so it copies p1..p5 (f1..f5) plus one new message + // (f6). The shared prefix is p1..p5, so block 1 is translatable but block 2 + // (anchored at p6, outside the prefix) must be skipped. + const parentState = createSessionState() + parentState.sessionId = PARENT_ID + parentState.isSubAgent = false + const parentMessages: WithParts[] = [ + makeUserMessage("p1", "hello", 1000, PARENT_ID), + makeAssistantMessage("p2", [makeTextPart("hi")], 1001, PARENT_ID), + makeUserMessage("p3", "do a task", 1002, PARENT_ID), + makeAssistantMessage("p4", [makeTextPart("doing it")], 1003, PARENT_ID), + makeAssistantMessage("p5", [makeCompressPart("call-1", COMPRESS_INPUT)], 1004, PARENT_ID), + makeUserMessage("p6", "more", 1005, PARENT_ID), + makeAssistantMessage("p7", [makeTextPart("ok")], 1006, PARENT_ID), + makeUserMessage("p8", "done", 1007, PARENT_ID), + makeAssistantMessage("p9", [makeCompressPart("call-2", COMPRESS_INPUT_2)], 1008, PARENT_ID), + ] + const rebuilt = rebuildCompressionState(parentState, parentMessages, buildConfig(), logger) + assert.equal(rebuilt, 2, "parent should rebuild both blocks") + await saveSessionState(parentState, logger) + + const forkMessages: WithParts[] = [ + makeUserMessage("f1", "hello", 1000, FORK_ID), + makeAssistantMessage("f2", [makeTextPart("hi")], 1001, FORK_ID), + makeUserMessage("f3", "do a task", 1002, FORK_ID), + makeAssistantMessage("f4", [makeTextPart("doing it")], 1003, FORK_ID), + makeAssistantMessage("f5", [makeStrippedCompressPart("call-1")], 1004, FORK_ID), + makeUserMessage("f6", "new fork turn", 2000, FORK_ID), + ] + const client = makeClient(parentMessages) + const forkState = freshForkState(false) + + const recovered = await recoverFromParentState( + client, + forkState, + forkMessages, + PARENT_ID, + logger, + ) + + // Only block 1 is within the shared prefix; block 2 (anchor p6) is skipped. + assert.equal(recovered, 1) + assert.equal(forkState.prune.messages.blocksById.size, 1) + const block = forkState.prune.messages.blocksById.get(1)! + assert.deepEqual(block.effectiveMessageIds, ["f1", "f2", "f3", "f4"]) + assert.ok(forkState.prune.messages.byMessageId.has("f1")) + assert.ok( + !forkState.prune.messages.byMessageId.has("f6"), + "post-fork message must not be pruned", + ) +}) From cfa2f1f6f4d504377d6c95d0c60db0c12da60318 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Wed, 9 Sep 2026 20:29:50 +0800 Subject: [PATCH 2/2] docs: record master merge + conflict resolution in devlog --- .../2026-09-09_fork-parent-state-transfer/WORKLOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md b/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md index 85e5d807..40ae16cb 100644 --- a/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md +++ b/devlog/2026-09-09_fork-parent-state-transfer/WORKLOG.md @@ -3,7 +3,7 @@ - Task ID: `2026-09-09_fork-parent-state-transfer` - Home Repo: `opencode-acp` - Status: InProgress -- Updated: 2026-09-09 09:35 +- Updated: 2026-09-09 20:30 ## 1. Summary @@ -19,6 +19,16 @@ | Commit | Description | |--------|-------------| | `b389f61` | feat: recover fork compression blocks from parent state when historical compress inputs are stripped | +| `5ba7484` | merge: resolve conflict with master (storagePath warning + fork parent-state transfer) | + +### Conflict resolution (2026-09-09) + +Merged master (`5135dfd`, incl. #380 storagePath) into the PR branch. Single conflict in +`lib/state/state.ts` `ensureSessionInitialized()`: this PR's fork-recovery comment block +overlapped the new storagePath default-location warning. Resolution keeps both — the +storagePath warning runs first, then the fork recovery path (parent-state transfer → +historical replay fallback). No behavior change on either side. Verified after merge: +`npm run typecheck` clean, `npm run build` success, `npm run test` → 1138 pass / 0 fail. ### Key Files