Closes M11 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
9.8 KiB
ADR: Prompt Async Gate
Status
Accepted for v4.2.0.
This decision applies to every production route that sends an internal message through an OpenCode session API.
The mandated implementation is src/shared/prompt-async-gate.ts.
The root AGENTS.md invariant named "Internal message injection is dangerous"
is the policy authority for this ADR.
The static audit src/shared/prompt-async-route-audit.test.ts enforces the
production side of this decision.
Route-specific tests must still prove behavior for each internal message path.
Context
Issue 4012 reported duplicate streaming output after internal message injection.
The visible symptom was repeated assistant output in a live parent session.
The underlying failure mode was a race between OpenCode session state and OMO continuation hooks.
OMO has several routes that can decide to wake or continue a session:
- background task completion notifications
- runtime fallback retries
- team mailbox delivery
- recovery continuations
- CLI run resume paths
- Claude Code hook delivery
- sync and background subagent prompts
These routes can observe the same idle, completion, or error edge.
Without a shared gate, two routes can dispatch the same internal prompt into the same parent session.
OpenCode also exposes a subtle durability gap.
session.promptAsync can return before the prompt is durably accepted by the
target session.
A later session.error event can still arrive for the same attempt.
That means a route can think it finished while another hook still sees the session as eligible for recovery.
The old pattern was unsafe:
await client.session.promptAsync({
path: { id: sessionID },
body: { text: message },
})
The unsafe properties were:
- No per-session reservation before dispatch.
- No shared active-session check.
- No post-dispatch hold for late failures.
- No timeout around a hung dispatch.
- No central log trail for skipped or failed dispatches.
- No static audit that could block new raw prompt routes.
Local guards inside each feature were not enough.
Different hooks can run in the same process and see different snapshots of session state.
They need one shared reservation map keyed by session ID.
The root AGENTS.md now states the invariant:
Treat every session.prompt / session.promptAsync call as a write to shared
session state. Production code may call them only inside
src/shared/prompt-async-gate.ts.
This ADR records the architecture behind that invariant.
Decision
All production internal message injection must go through
src/shared/prompt-async-gate.ts.
The module exports two gate functions:
export async function promptAsyncAfterSessionIdle<TInput>(args: {
client: PromptAsyncClient<TInput>
sessionID: string
input: TInput
source: string
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
checkStatus?: boolean
}): Promise<PromptAsyncGateResult>
export async function promptAfterSessionIdle<TInput>(args: {
client: PromptClient<TInput>
sessionID: string
input: TInput
source: string
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
checkStatus?: boolean
}): Promise<PromptAsyncGateResult>
The gate returns a discriminated result instead of throwing for expected races:
export type PromptAsyncGateResult =
| { status: "dispatched"; response: unknown }
| { status: "active" }
| { status: "reserved"; reservedBy: string }
| { status: "unavailable" }
| { status: "failed"; error: unknown }
Callers must treat active and reserved as successful suppression.
They mean another actor owns the session or the user is already active.
They are not retry signals by default.
Every call must provide a stable source string.
The source identifies the route that reserved the session.
Recommended source format:
const source = `background-agent:${taskID}`
The reservation flow is:
- Prune expired reservations.
- Reject if the session already has an active reservation.
- Reserve the session before waiting or dispatching.
- Wait for idle settle time.
- Check current session status unless the caller opted out for a proven reason.
- Dispatch through
session.promptAsyncorsession.prompt. - Keep a short post-dispatch hold after an attempted dispatch.
- Release only after the hold expires or through an intentional recovery path.
The default timing constants are part of the decision:
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
The post-dispatch hold is required because promptAsync returning does not prove
that all related OpenCode events have drained.
The dispatch timeout is required because a stuck OpenCode API call must not hold the reservation forever.
The timeout is a circuit breaker, not a synchronization primitive.
Callers must not set postDispatchHoldMs: 0.
The static audit rejects that pattern.
If a caller needs custom behavior, it must add a route-specific regression test that proves duplicate dispatch cannot occur.
The gate owns the raw prompt calls:
const promptAsync = client.session?.promptAsync
if (typeof promptAsync !== "function") {
return { status: "unavailable" }
}
return dispatchAfterSessionIdle({
sessionName: "promptAsync",
client,
sessionID,
input,
source,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
dispatch: (dispatchInput) => promptAsync(dispatchInput),
})
Production code outside this module must not access these APIs directly:
client.session.prompt(...)
client.session.promptAsync(...)
client["session"]["promptAsync"](...)
const { promptAsync } = client.session
Reflect.apply(client.session.promptAsync, client.session, [input])
Type guards may check that promptAsync exists when the eventual dispatch still
routes through the shared gate.
The allowlist in the audit must stay small and justified.
The gate also exposes reservation release helpers for intentional recovery:
releasePromptAsyncReservation(sessionID, {
reservedBy: "model-suggestion-retry",
})
releasePromptAsyncReservation(sessionID, {
reservedByPrefix: "runtime-fallback:",
})
Prefix release is allowed only for prefixes that end with :.
This prevents broad accidental releases such as runtime matching unrelated
sources.
Release helpers exist for rollback and retry flows.
They must not be used as a normal cleanup path after dispatch.
Consequences
Positive consequences:
- Duplicate internal dispatches collapse to one reservation winner.
- Late
session.errorevents no longer trigger immediate duplicate retries. - Internal message routes share logging and result semantics.
- Tests can reason about a single gate instead of many ad hoc guards.
- New raw prompt routes are blocked by a static audit.
- Retry flows can release only their own reservation source.
- Hung dispatches fail closed through a timeout.
Negative consequences:
- Internal prompt injection has a small default latency from idle settling.
- A post-dispatch hold can delay a legitimate retry by 250 ms.
- Callers must handle
PromptAsyncGateResultinstead of assuming dispatch. - Tests that mock session APIs may need to model reservation state.
- Any new route must add route-specific duplicate-injection coverage.
Operational consequences:
- CI green is not enough for race fixes tied to issue 4012.
- Maintainers must re-run the documented reproducer against the fix commit.
- Logs containing
[prompt-async-gate]are the first place to inspect when a wake, retry, or recovery message does not appear.
Testing consequences:
src/shared/prompt-async-gate.test.tscovers gate behavior.src/shared/prompt-async-route-audit.test.tsblocks raw production prompt routes.- Route owners must add regression tests for the specific trigger they wire.
- Tests must not rely on sleeping to wait for the post-dispatch hold.
Design constraints that remain open:
- The reservation map is process-local.
- Cross-process OpenCode sessions still rely on the session API and event stream.
- The gate does not deduplicate different semantic prompts for the same session.
- The gate prevents concurrent injection, not incorrect caller intent.
Rejected alternatives:
-
Keep route-local guards.
This failed because hooks observe the same edge from different modules.
-
Disable recovery on any recent prompt event.
This would hide valid recovery paths and lose task state.
-
Use a global fixed delay after every dispatch.
A delay without a reservation does not prevent another route from entering.
-
Treat
promptAsyncsuccess as durable acceptance.Issue 4012 showed that later OpenCode errors can still arrive.
-
Allow raw prompt calls with code review discipline.
The risk is architectural, so the invariant needs an automated audit.
Migration rule:
const result = await promptAsyncAfterSessionIdle({
client,
sessionID,
input,
source: "runtime-fallback:retry",
})
if (result.status === "failed") {
restoreOptimisticState()
}
The caller owns any optimistic task or loop state it changed before dispatch.
If dispatch is skipped, unavailable, or failed, the caller must restore state when needed.
References
- Issue 4012: https://github.com/code-yeongyu/oh-my-openagent/issues/4012
- Introduction PR 4034: https://github.com/code-yeongyu/oh-my-openagent/pull/4034
- Hardening commit:
b333a5280fix(prompt-async-gate): add dispatch timeout, shared runner, harden prefix release - Test commit:
f93d7297ctest(prompt-async-gate): cover dispatch timeout and post-dispatch error hold - Retry release commit:
ff1b15d53fix(model-suggestion-retry): release reservation before retry attempt - Root invariant:
AGENTS.md, sectionInternal message injection is dangerous - Implementation:
src/shared/prompt-async-gate.ts - Static audit:
src/shared/prompt-async-route-audit.test.ts