{"record":{"id":"941f030fa78bab50","repo":"paperclipai/paperclip","slug":"opencode-request-input-requestid-belongs-to-a-s","errorCode":null,"errorMessage":"OpenCode request ${input.requestId} belongs to a stale turn","messagePattern":"OpenCode request (.+?) belongs to a stale turn","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts","lineNumber":633,"sourceCode":"      structuredClone(request),\n    );\n  }\n\n  async resolveRuntimeRequest(input: {\n    requestId: string;\n    turnId: string;\n    resolution: HarnessRuntimeRequestResolution;\n  }): Promise<void> {\n    const pending = this.#pendingRuntimeRequests.get(input.requestId);\n    if (!pending)\n      throw new Error(\n        `OpenCode request ${input.requestId} is no longer pending`,\n      );\n    if (\n      pending.request.turnId !== input.turnId ||\n      this.#activeTurnId !== input.turnId\n    ) {\n      throw new Error(\n        `OpenCode request ${input.requestId} belongs to a stale turn`,\n      );\n    }\n    const resolution = parseHarnessRuntimeRequestResolution(\n      pending.request.requestKind,\n      input.resolution,\n      pending.request.input,\n    );\n    if (pending.settling)\n      throw new Error(\n        `OpenCode request ${input.requestId} is already settling`,\n      );\n    pending.settling = true;\n    const submit = async (operation: Promise<unknown>) => {\n      try {\n        await operation;\n      } catch (error) {\n        if (this.#pendingRuntimeRequests.get(input.requestId) === pending)","sourceCodeStart":615,"sourceCodeEnd":651,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts#L615-L651","documentation":"After finding the pending request, `resolveRuntimeRequest` verifies the caller's `turnId` matches both the turn the request was raised in (`pending.request.turnId`) and the session's currently active turn (`#activeTurnId`). A mismatch means the resolution belongs to an earlier, finished turn and is refused so answers cannot leak across turn boundaries.","triggerScenarios":"Calling `resolveRuntimeRequest` with a `turnId` different from `pending.request.turnId`, or after the active turn advanced past the one owning the request (old turn completed, new turn started, request map not yet cleaned).","commonSituations":"A caller caches `(requestId, turnId)` pairs and replays them after a turn restart; turn id captured from a stale event before a reconnect re-minted the active turn; mixing requests between two restored session instances.","solutions":["Read the authoritative turn id from `session.pendingRuntimeRequests()` (`request.turnId`) and pass exactly that value.","Confirm via `await session.snapshot()` that `activeTurnId` still equals the request's turn; if not, abandon the resolution — the request is expired.","On receiving this error, drop the pending answer and surface the request as expired rather than retrying with the same stale turnId.","Ensure the caller's turn id comes from the same event stream instance (`events()`) that raised the request, not a different session handle."],"exampleFix":"// before\nawait session.resolveRuntimeRequest({ requestId, turnId: cachedTurnId, resolution });\n\n// after\nconst pending = session.pendingRuntimeRequests().find(r => r.requestId === requestId);\nawait session.resolveRuntimeRequest({ requestId, turnId: pending.turnId, resolution });","handlingStrategy":"validation","validationCode":"const pending = session.pendingRuntimeRequests().find(r => r.requestId === requestId);\nif (!pending || pending.turnId !== turnId) throw new StaleTurnError(requestId);","typeGuard":"function turnMatches(session, requestId, turnId) {\n  const p = session.pendingRuntimeRequests().find(r => r.requestId === requestId);\n  return !!p && p.turnId === turnId;\n}","tryCatchPattern":"try {\n  await session.resolveRuntimeRequest({ requestId, turnId, resolution });\n} catch (e) {\n  if (e.message.includes('belongs to a stale turn')) {\n    // abandon the answer; surface the request as expired\n  } else throw e;\n}","preventionTips":["Take turnId from the same event stream that raised the request.","Verify `snapshot().activeTurnId` still matches before resolving.","Do not replay stored (requestId, turnId) pairs across turn boundaries.","Expire queued answers when the owning turn completes."],"tags":["runtime-request","stale-turn","opencode","turn-mismatch"],"backgroundTag":"invalid-state-transition","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}