fix: context-limit safety net for spawn+resume mode (#346) - #349
fix: context-limit safety net for spawn+resume mode (#346)#349ranxianglei wants to merge 2 commits into
Conversation
In headless per-message spawn+resume mode the model context limit was
never known when the messages-transform pipeline ran: the system hook
(its only writer) runs after messages.transform within a request and
never persisted its value, and the init-time catalog seed races server
readiness. Every percentage threshold therefore resolved to undefined
and the entire safety net (nudges, GC, in-flight truncation) was
silently disabled, letting sessions grow to the length-rejection wall
(~229k tokens on a 262144 window) in an infinite empty-response retry
loop.
- Persist modelContextLimit + identity from the system hook on change
- Lazy one-shot catalog hydration during a request on catalog miss
- New compress.contextLimitFallback (default 128000, 0 disables) drives
thresholds/GC/truncation when the model limit is unknown
- In-flight truncation becomes overhead-aware:
min(gc threshold, limit - systemPromptTokens - 16384 output reserve)
- ERROR log ("ACP hard guard") when post-transform tokens exceed the
model budget
- Internal-agent (title/summary/compaction) system prompts no longer
overwrite the session limit
Tests: 1049 pass (20 new); all 11 behavioral tests verified to fail
with the source changes reverted.
📦 Built Plugin ArtifactBranch: Option A — Install from npm PR tag (recommended)opencode plugin opencode-acp@pr-349 --globalEach push to this PR publishes a new version under the Option B — Install from GitHubopencode plugin "github:ranxianglei/opencode-acp#2026-08-28_spawn-resume-context-limit" --globalOption C — Download artifact
tar xzf opencode-acp-pr349.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
This comment is automatically updated on each push. |
Source: - Correct the truncation-threshold comment (dual-regime rationale: provider-usage counts already include the system prompt — margin; fallback counts exclude it — exact bound) - Throttle the 'window too small' ERROR to once per session - hydrateAndResolve: in-flight promise instead of a boolean so concurrent callers share one hydration - System hook: only write the model identity pair when present (a limit without identity no longer clobbers the #312 staleness pair) - Extend contextLimitFallback docs (switch-invalidation case, per-model precedence) in config.ts, dcp.schema.json, CONFIGURATION.md/zh Tests: - Restore 7 hollowed truncation tests (tiny windows now bail before the protection/skip branches run) — branches execute again - Add the §5.7 multi-turn growth-cycle test (fallback-only config, preserveRecentMessages 20; asserts shouldInjectThisTurn, baseline, and anchor sets per turn; turn-anchor assertion is the pre-fix discriminator) - Poll for the persisted state file instead of a fixed sleep; restore XDG env vars in finally - Stub hydrateAndResolve mirrors the real once-per-process hydration - Remove the phantom 'strategies' config field from the new test factory - Faster MEDIUM_OUTPUT fixture (2201 tokens, verified) for the new truncation tests; correct stale threshold comments Verification bugs found and fixed in this round: state.sessionId is string | null (throttle key), missing lib/messages/inject barrel import.
|
[bot] Dual-agent review complete (independent source + test reviewers, per AGENTS.md §5.3/§5.6). No blockers from either. Follow-up commit Source (reviewer 1) — all minors, all fixed:
Tests (reviewer 2) — 2 majors, all fixed:
Counting basis for the pre-fix failure check (PR body said "11"): 16 tests assert the new behavior (19 if the fallback test file's import failure is counted); the "11" = the 11 named behavioral tests — the remaining 5 (hard-guard-silent, 2 fixed compat tests, etc.) pass by design pre-fix as controls. Two verification bugs were caught and fixed in this round: |
[bot] Independent verification of final PR state (head Re-ran the full verification suite on the branch after the follow-up review commit, and did a sanity pass over the diff: Local verification (this session, on
CI (GitHub Actions on PR state: open, mergeable, Sanity review of the diff — no issues found:
Status: dual-agent review (per §5.3/§5.6) was completed in the prior round with all findings addressed in |
[bot] Re-checked the thread — this forward re-sent the dual-agent review summary (floor 3); there's no new request beyond what's already addressed, and the PR is unchanged since my last verification:
Nothing further needed from me. The PR is ready — merge is a human-only operation (§5.1.1.2). When you're ready: #349 |
Root cause (issue #346)
In headless per-message spawn+resume mode the model context limit was never known when the messages-transform pipeline ran:
state.modelContextLimit, and it runs aftermessages.transformwithin a request — so on the (only) request a spawned process handles, the limit is stillundefinedduring all threshold math.saveSessionStateonly runs inside the messages-transform pipeline) — the next spawned process started fromundefinedagain. Learned and lost, every message, forever.hydrateModelLimitsFromClient, fire-and-forget) races server readiness in spawned processes; nothing retried.With
state.modelContextLimit === undefined, every percentage threshold (maxContextLimit/minContextLimit= "80%",emergencyThresholdPercent,gc.majorGcThresholdPercent= "100%") resolved toundefined, and every consumer treated that as "do nothing" — nudges, batch-cleanup GC, and in-flight tool-output truncation were all silently disabled. Sessions grew to the length-rejection wall (229,479 / 229,535 tokens on a 262144 window + ~17K system + 16Kmax_tokens) → immediate rejection → exit-0 empty run → infinite retry loop.A second, independent defect: even with a known limit, in-flight truncation started at 100% of the window — already past the serving wall once system prompt + tool schemas +
max_tokensare added.Changes
modelContextLimit+ model identity on change, so a freshly spawned process resumes with the limit known. (State file shape is additive; the fields already existed.)messages.transform, the catalog is hydrated once per process fromclient.config.providers()(the server is guaranteed up during a request) before threshold math.compress.contextLimitFallback(new, default128000,0disables) — when the limit is genuinely unknown, a configurable fallback window drives nudge thresholds, the emergency override, batch cleanup GC, and in-flight truncation. The real model limit always takes precedence. Documented in CONFIGURATION.md/zh + schema + validation.min(gc.majorGcThresholdPercent × limit, limit − systemPromptTokens − 16384)(OUTPUT_RESERVE_TOKENS). With the production numbers (limit 262144, system ~17K, 229479 tokens) truncation now fires at 228760 instead of never. A window that cannot fit the overhead logs an ERROR and bails.limit − systemPromptTokens − 16384, ACP logs a loudERROR("ACP hard guard: …") with the budget breakdown. The exit-0 empty response itself is opencode-core behavior and cannot be changed from a plugin (upstream report candidate).Also fixed along the way: internal-agent (title/summary/compaction) system prompts no longer overwrite the session limit with their own small model's limit.
Testing
tests/model-switch-limits.test.ts,tests/truncate-tools.test.ts, newtests/context-limit-fallback.test.ts).hydrateAndResolve×3, hard guard, production wall repro, overhead bail, fallback truncation) — re-applying the fixes turns them green.systemPromptTokens17000, currentTokens 229479 (the exact production token count) → truncation fires; pre-fix it was a no-op.npm run typecheck+npm run buildpass.Rollback / escape hatches
"compress": { "contextLimitFallback": 0 }restores the exact legacy behavior.max_tokensdeployments: lowergc.majorGcThresholdPercent(themin()keeps the stricter bound).Devlog:
devlog/2026-08-28_spawn-resume-context-limit/(REQ + DESIGN + WORKLOG).