Skip to content

fix: migrate legacy 4-digit CompressionBlock boundary refs at load and fork transfer - #416

Open
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-17_legacy-block-ref-migration
Open

ranxianglei wants to merge 4 commits into
masterfrom
2026-09-17_legacy-block-ref-migration

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Addresses the applicable item of #415 (R4: "Legacy fork state normalizes parent message-ref maps but not four-digit CompressionBlock.startId/endId boundaries"). The other nine items of #415 were triaged as not applicable to this V1-only repo — see the issue comment with per-item evidence.

Problem

Pre-1.1.0 persisted states carry 4-digit message refs (m0001). At session load, lib/state/state.ts migrates the messageIds ref maps to canonical 5-digit form (m00001) and re-saves — but CompressionBlock.startId/endId boundaries are never migrated:

  1. Own-session load: loadPruneMessagesState passes boundaries through verbatim, so a legacy session's saved file keeps mixed-width refs permanently.
  2. Fork transfer: restoreForkCompressionState reads the parent's raw persisted record (loadSessionState does not normalize blocks) and spreads ...block, copying 4-digit boundaries into otherwise canonical fork state.

Impact is low severity (no functional breakage — boundary resolution uses raw IDs + blockId, never byRef), but concrete: hideConsumedCompressCalls range-key dedup (lib/compress/hide-consumed.ts:84) stops matching consumed compress calls for legacy blocks → minor context duplication; export/recap display carries inconsistent widths.

Fix

  • lib/message-ids.ts: new exported migrateMessageRef(ref) — 4→5 digit via existing parseMessageRef/formatMessageRef; non-message values (empty strings, bN block refs, free text, out-of-range indices) pass through unchanged.
  • lib/state/utils.ts: loadPruneMessagesState migrates block startId/endId.
  • lib/state/rebuild.ts: restoreForkCompressionState normalizes translated block boundaries.
  • Self-healing: after one load+save cycle under new code, files are canonical on disk.

Tests

tests/legacy-block-boundary-migration.test.ts — 5 tests:

  • migrateMessageRef unit coverage incl. edge cases (m00000, 6-digit, b3, whitespace/case)
  • pass-through guard for non-message values (green before fix by design)
  • own-load migration, fork-transfer migration, full ensureSessionInitialized integration with self-healing re-save assertion

Red/green proof (§5.7.3): behavioral tests written first fail with actual: 'm0001' vs expected: 'm00001' at all three production paths; all green after the fix.

Verification

  • typecheck ✓, build ✓, prettier clean on touched files ✓
  • Full suite: 1272 tests, 1270 pass, 2 fail — both failures reproduce identically on clean master 06efd39 (sandbox /tmp is read-only: soft-block.test.ts EACCES mkdir, inactive-block-decompress.test.ts toFile path guard); pre-existing environment incompatibility, unrelated to this change.
  • No version bump (non-release branch).

Related: #415 (item R4); related code path introduced alongside #409.

…d fork transfer

Pre-1.1.0 persisted states carry 4-digit message refs (m0001). Session load
migrates the messageIds ref maps to canonical 5-digit form but never the
CompressionBlock.startId/endId boundaries, so legacy sessions keep mixed-width
refs forever: hide-consumed range-key dedup stops matching consumed compress
calls, and fork transfers (which read the parent's raw persisted record,
bypassing loadPruneMessagesState) copy 4-digit boundaries into otherwise
canonical fork state.

- add migrateMessageRef() to lib/message-ids.ts (4→5 digit; non-refs pass through)
- apply in loadPruneMessagesState (own-session load)
- apply in restoreForkCompressionState block translation (fork transfer)
- 5 new tests with red/green proof (3 behavioral paths + pass-through guard)

Related: #415
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-17_legacy-block-ref-migration (2f54a14)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-416 --global

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

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-17_legacy-block-ref-migration" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr416.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] 🏷 Picking this up. This issue tracks the already-open PR #416 (head 2026-09-17_legacy-block-ref-migrationmaster); the fix is implemented and pushed as commit 1a68306. Per our PR-handling rules I'm not re-doing the work — I'm independently verifying the branch against the description and checking CI, then I'll post a concrete conclusion. Initial CI snapshot: pr-validation ✅, build ✅, test (22) ✅, test (24) ✅, build-artifact ✅, e2e still running.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Verification complete — PR #416 (1a68306, 2026-09-17_legacy-block-ref-migrationmaster) checked out fresh and independently verified against the description. Conclusion: code is correct, CI fully green, ready for the second independent review + human merge.

Code review (per file)

  • lib/message-ids.ts — new export migrateMessageRef(ref): 4→5 digit via existing parseMessageRef/formatMessageRef; non-message values pass through unchanged. Edge cases verified against the actual regex/range (/^m(\d{4,5})$/, index ∈ [1, 99999], width-5 padding): m0001m00001, m00999m00999, 5-digit idempotent, m00000 / m123456 / b3 / empty / free text pass through, whitespace + case handled by parseMessageRef.
  • lib/state/utils.tsloadPruneMessagesState migrates block startId/endId, preserving the existing typeof === "string" guards.
  • lib/state/rebuild.tsrestoreForkCompressionState overrides the spread startId/endId with migrated values (later properties win over ...block). Non-blocking nit: no typeof guard here (unlike utils.ts) — acceptable since input is typed PersistedSessionState and sibling fields (anchorMessageId, …) rely on the same shape trust; flagging only for the record.
  • Impact mechanism confirmed as described: hideConsumedCompressCalls builds live range keys from block boundaries (rangeKey(block.startId, block.endId)), so mixed-width refs break dedup matching → consumed calls stay visible. Low severity assessment holds: boundary resolution itself never goes through byRef.

Independent local runs (fresh checkout of 1a68306)

  • npm run typecheck
  • Targeted: 5/5 in tests/legacy-block-boundary-migration.test.ts pass
  • Full suite: 1272 tests, 1270 pass, 2 fail — exactly matching the PR body. Both failures reproduce identically on clean master 06efd39:
    • soft-block.test.ts: EACCES: permission denied, mkdir '/tmp/opencode-dcp-dangerous-*'
    • inactive-block-decompress.test.ts: toFile guard rejects the hardcoded /tmp/test-inactive-block-decompress.txt path
    • Both are pre-existing environment incompatibilities (tests hardcode /tmp paths not writable in this sandbox), unrelated to this change.
  • Test file meets §5.6: imports real sources (no reimplementation), complete config factory incl. gc, behavioral red/green paths through all three production sites, self-healing test reads persisted JSON back after init.

CI & process

Remaining before merge: dual-agent review per §5.3 (code) + §5.6 (tests) — this verification counts as one agent's pass; one more independent agent review is required per policy. Then human merge (merges are human-only).

中文摘要:已独立核验 PR #416 —— 代码逐文件正确(含边界用例)、本地全量 1272 测试中 2 个失败在干净 master 06efd39 上同样复现(沙箱 /tmp 只读的预存环境问题,与本改动无关)、CI 六项全绿、devlog 齐全、未动版本号;代码可合并,尚差第二个独立 agent review,合并由人工执行。

…aw boundary values

Dual-agent review of PR #416 found that migrating only block boundaries
would regress hideConsumedCompressCalls batch filtering for legacy
(pre-1.1.0) sessions: pre-fix both sides were consistently 4-digit, and
immutable historical tool inputs stay 4-digit forever.

- rangeKey now normalizes both sides via migrateMessageRef, with
  non-string pass-through for legacy records that omit boundaries
- restoreForkCompressionState guards typeof before migrating (raw JSON)
- migrateMessageRef JSDoc documents inherited trim/lowercase semantics
- new RED-confirmed regression test: migrated 5-digit block keys match
  legacy 4-digit tool inputs in batch filtering
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 ## Dual-agent review record (§5.3 code + §5.6 tests)

Two independent reviews were run against the original commit (1a68306):

Code review: Request changes → fixed in c2651ca

  • MAJOR: block-side-only migration would regress hideConsumedCompressCalls batch filtering for legacy pre-1.1.0 sessions — pre-fix both sides of the range key were consistently 4-digit; immutable historical tool inputs stay 4-digit forever, so a 5-digit-migrated block key would never match and consumed batch-mate summaries would leak back into context. Fixed by width-normalizing both sides of rangeKey via migrateMessageRef, plus non-string pass-through for legacy records that omit boundaries (first fix attempt crashed 3 existing test files with TypeError on undefined.trim() — caught by the suite, guarded).
  • MINOR: missing typeof === "string" guard in restoreForkCompressionState (parent record is raw persisted JSON) — added.
  • NITs: JSDoc now documents the inherited trim/lowercase matching semantics of migrateMessageRef; REQ.md impact analysis corrected (pre-fix consumer was width-matched — the risk was introduced by the migration itself, not present before).

Test review: Approve

  • All 6 new tests verified against source; red/green independently reproduced in an isolated checkout.
  • Minor, accepted as follow-up (not fixed here): buildConfig() factory omits optional PluginConfig fields — copied verbatim from existing tests/rebuild.test.ts; test files are excluded from tsconfig typecheck and gc is present.

Post-fix verification: typecheck + build pass; full suite 1273 tests / 1271 pass / 2 fail — the 2 failures reproduce identically on clean master (sandbox environment incompatibility, unrelated to this change). Prettier clean on all touched lines (two files carry pre-existing master drift on unrelated lines, left untouched to keep the diff minimal).

Issue #415 follow-up review found decompress's toFile validation was
lexical-only (path.resolve + path.relative), so a symlink inside an
allowed root could redirect the write outside it, and writeFile then
followed the link.

- new lib/compress/tofile-target.ts: resolveSafeToFileTarget with three
  fail-closed layers — lexical containment, physical containment via
  realpath of the deepest existing ancestor vs physical allowed roots,
  final-component symlink refusal
- decompress.ts validates through it and writes via fs/promises open
  with O_NOFOLLOW (POSIX) and mode 0o600, closing the check-then-write
  window
- 8 new tests in tests/decompress-tofile-symlink.test.ts incl.
  intermediate-dir symlink escape and final-component symlink cases;
  fixtures inject allowed dirs under $TMPDIR
…sue #415)

Review of the round-2 decompress.toFile hardening (7a0d154) found:

- CRITICAL: physicallyAllowed required containment in ALL allowed roots
  (every(!containsOrIs(...) === false) && some(...)). The defaults are two
  disjoint roots (os.tmpdir(), ~/.cache/opencode), so with default config
  every legitimate toFile target was rejected. Fixed to some(containsOrIs)
  — containment in at least one existing root suffices. Regression test:
  'accepts targets under either of two disjoint allowed roots' (RED against
  the old expression; single-root fixtures made every/some agree).
- Node 22 compat: FileHandle.writeFile() exists only in Node >= 23 while CI
  runs Node 22/24 — use handle.write(Buffer.from(...)) instead.
- New test: intermediate-dir symlink escaping across multiple roots (link
  into a second allowed root stays legitimate; link into a third dir is
  rejected) — not expressible with single-root fixtures.
- Docs: O_NOFOLLOW noted POSIX-only; hardlinks documented as out of scope.
- Restored Prettier style on hand-reformatted lines (diff vs HEAD now
  functional-only); all touched files pass prettier --check.

Verification: typecheck pass; build pass; decompress-tofile-symlink 10/10;
full suite 1283 tests / 1281 pass / 2 fail — same 2 pre-existing sandbox
failures as clean master (/tmp read-only in daemon sandboxes).
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.

1 participant