docs(prompt-gate): document unified dispatch invariant

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-17 17:16:18 +09:00
parent 6768decddb
commit 98df0a43e3
4 changed files with 13 additions and 17 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ Schema autocomplete: `"$schema": "https://raw.githubusercontent.com/code-yeongyu
- **OpenClaw bidirectional:** Outbound dispatchers fire on session events; inbound daemon polls Discord/Telegram and `send-keys` replies into the tracked tmux pane.
- **Internal message injection is dangerous:** OpenCode의 stupid한 설계로 플러그인이 `session.prompt` / `session.promptAsync` 같은 메인 세션 메시지 API를 통해 메인 시스템을 망가뜨릴 수 있다.
- Root cause to remember: OpenCode `promptAsync` returns before the prompt is durably accepted, and later failures can arrive as `session.error`. Multiple OMO hooks/tools can observe the same idle/error/completion edge and inject the same internal message into a live parent session.
- 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`; all other routes must use `promptAsyncAfterSessionIdle`, `promptAfterSessionIdle`, or a proven equivalent gate.
- 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`; all other routes must use `dispatchInternalPrompt({ mode: "async" | "sync", ... })` or a proven equivalent gate.
- Required gate semantics: reserve per session before dispatch, check active session state, keep a short post-dispatch hold, release only on intentional abort/recovery paths, and restore optimistic task/loop state when dispatch is skipped or fails later.
- Forbidden patterns: raw prompt calls outside the shared gate, `postDispatchHoldMs: 0`, no-session fallback to raw prompt, and new internal message routes without duplicate-injection regression tests.
- Tests must pin both the shared invariant and the route behavior: update the static raw-prompt audit, then add route-specific tests proving concurrent/live/idle/error triggers collapse to one dispatch. Cover background completion wakes, fallback retries, team mailbox live delivery, recovery continuations, CLI run resumes, Claude Code hook injections, and sync/background subagent prompts.
+1 -1
View File
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `createPluginModule` test seam moved out of public API surface to `src/testing/create-plugin-module.ts`. New public exports for the prompt-async-gate primitives: `promptAsyncAfterSessionIdle`, `promptAfterSessionIdle`, `releasePromptAsyncReservation`, `DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS`, `DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS`.
- `createPluginModule` test seam moved out of public API surface to `src/testing/create-plugin-module.ts`. New public exports for the prompt-async-gate primitives: `dispatchInternalPrompt`, `releasePromptAsyncReservation`, `DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS`, `DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS`.
- `ParentWakeNotifier` module (`src/features/background-agent/parent-wake-notifier.ts`) extracted from `BackgroundManager`. Background-agent parent-wake state now lives in its own narrow class with dependency-injected client, directory, and notification enqueue callback.
### Changed
+10 -14
View File
@@ -63,16 +63,12 @@ The root `AGENTS.md` now records the governing invariant in the section
Create `src/shared/prompt-async-gate.ts` as the single production owner of raw
OpenCode prompt dispatch.
The gate exposes the public wrappers that production callers must use:
The gate exposes one public dispatcher that production callers must use:
```ts
export function promptAsyncAfterSessionIdle(
options: PromptAsyncAfterSessionIdleOptions,
): Promise<PromptAsyncGateResult>
export function promptAfterSessionIdle(
options: PromptAfterSessionIdleOptions,
): Promise<PromptAsyncGateResult>
export function dispatchInternalPrompt(
options: InternalPromptDispatchArgs,
): Promise<InternalPromptDispatchResult>
```
The gate coordinates callers with a module-global reservation map:
@@ -125,16 +121,16 @@ export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
`session.prompt` call with `Promise.race`. A hung OpenCode API call must fail
closed instead of holding a reservation forever.
Both public gate helpers delegate to one internal runner:
The public dispatcher delegates to one internal runner:
```ts
dispatchAfterSessionIdle<TInput>(args)
```
`promptAsyncAfterSessionIdle` passes a `session.promptAsync` dispatcher.
`promptAfterSessionIdle` passes a `session.prompt` dispatcher. Sharing the
runner keeps reservation, hold, timeout, logging, and active-session behavior
identical for async and sync prompt routes.
`dispatchInternalPrompt({ mode: "async", ... })` binds `session.promptAsync`.
`dispatchInternalPrompt({ mode: "sync", ... })` binds `session.prompt`.
Sharing the runner keeps reservation, hold, timeout, logging, and active-session
behavior identical for async and sync prompt routes.
The public gate result is a discriminated union. Callers must treat `active`
and `reserved` as successful suppression, not automatic retry signals. A route
@@ -198,7 +194,7 @@ optional chaining, and aliased or cast access patterns.
### Migration
Existing `session.prompt` and `session.promptAsync` callers must route through
`promptAfterSessionIdle` or `promptAsyncAfterSessionIdle`.
`dispatchInternalPrompt` with the matching dispatch mode.
Existing production callers were wired through the introduction PR #4034.
+1 -1
View File
@@ -16,7 +16,7 @@ const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
],
[
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
"runtime type guard checks promptAsync presence before gate-routed promptAsyncAfterSessionIdle",
"runtime type guard checks promptAsync presence before gate-routed dispatchInternalPrompt",
],
])