refactor(core): make prompt ID reuse idempotent (#43548)

This commit is contained in:
Kit Langton
2026-08-19 21:56:40 -04:00
committed by GitHub
parent c85b09de6f
commit 1d89e911e8
5 changed files with 41 additions and 55 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
+1 -1
View File
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
+6 -8
View File
@@ -608,10 +608,9 @@ const layer = Layer.effect(
: Effect.die(defect),
),
)
if (
admitted.type !== "user" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
)
// First admission wins: same-session reuse is idempotent and ignores the
// retried payload, metadata, and delivery mode.
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
if (activeShells.has(admitted.sessionID)) return admitted
@@ -883,10 +882,9 @@ const layer = Layer.effect(
: Effect.die(defect),
),
)
if (
admitted.type !== "synthetic" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
)
// First admission wins: same-session reuse is idempotent and ignores the
// retried payload, metadata, and delivery mode.
if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
yield* execution.wake(input.sessionID)
-17
View File
@@ -379,23 +379,6 @@ export const has = Effect.fn("SessionInbox.has")(function* (
return row !== undefined
})
export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => {
if (
input.type !== expected.item.type ||
input.delivery !== expected.item.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.item.type === "user")
return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload))
if (input.type === "synthetic" && expected.item.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload))
if (input.type === "compaction" && expected.item.type === "compaction") return true
if (input.type === "move" && expected.item.type === "move")
return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(Effect.asVoid)
+29 -29
View File
@@ -652,53 +652,52 @@ describe("Session.prompt", () => {
}),
)
it.effect("rejects reuse of one ID with a different prompt", () =>
it.effect("keeps the first admission when one ID is reused with a different prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.prompt({
const first = yield* session.prompt({
sessionID,
id: messageID,
text: "Fix the failing tests",
})
const failure = yield* session
.prompt({
sessionID,
id: messageID,
text: "Delete the failing tests",
resume: false,
})
.pipe(Effect.flip)
const retried = yield* session.prompt({
sessionID,
id: messageID,
text: "Delete the failing tests",
resume: false,
})
expect(failure._tag).toBe("Session.PromptConflictError")
expect(retried).toEqual(first)
expect(retried.payload.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toHaveLength(0)
expect(yield* admittedCount).toBe(1)
}),
)
it.effect("rejects reuse of one ID with a different delivery mode", () =>
it.effect("keeps the first admission's delivery mode when one ID is reused with another", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.prompt({
const first = yield* session.prompt({
id: messageID,
sessionID,
text: "Fix the failing tests",
resume: false,
})
const failure = yield* session
.prompt({
id: messageID,
sessionID,
text: "Fix the failing tests",
delivery: "queue",
resume: false,
})
.pipe(Effect.flip)
const retried = yield* session.prompt({
id: messageID,
sessionID,
text: "Fix the failing tests",
delivery: "queue",
resume: false,
})
expect(failure._tag).toBe("Session.PromptConflictError")
expect(retried).toEqual(first)
expect(retried.delivery).toBe("steer")
expect(yield* admittedCount).toBe(1)
}),
)
@@ -914,7 +913,7 @@ describe("Session.prompt", () => {
}),
)
it.effect("treats prompt metadata as durable retry identity", () =>
it.effect("keeps the first admission's metadata when one ID is reused with other metadata", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
@@ -928,11 +927,12 @@ describe("Session.prompt", () => {
const first = yield* session.prompt(input)
const retried = yield* session.prompt(input)
const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
const differing = yield* session.prompt({ ...input, metadata: { source: "plugin" } })
expect(retried).toEqual(first)
expect(differing).toEqual(first)
expect(first.payload.metadata).toEqual({ source: "api" })
expect(failure._tag).toBe("Session.PromptConflictError")
expect(yield* admittedCount).toBe(1)
}),
)
@@ -978,7 +978,7 @@ describe("Session.prompt", () => {
}),
)
it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () =>
it.effect("reconciles synthetic retries from the promoted message regardless of payload", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
@@ -991,11 +991,11 @@ describe("Session.prompt", () => {
})
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
const differing = yield* session.synthetic({ ...input, text: "Different completion" })
expect(entries[1]).toEqual(entries[0])
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } })
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
expect(differing).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } })
expect(yield* admittedCount).toBe(0)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
}),