diff --git a/.agents/command/.npmignore b/.agents/command/.npmignore
new file mode 100644
index 000000000..6524d100e
--- /dev/null
+++ b/.agents/command/.npmignore
@@ -0,0 +1,10 @@
+# Internal-only assets — never ship to npm registry.
+# See script/package-layout-exclusion.test.ts for the enforcing guard.
+# Root .npmignore does not work for directories listed in package.json#files
+# under Bun 1.3.x, so the guard lives co-located with the published content.
+__*/
+__*.md
+.private/
+.draft/
+.private.md
+.draft.md
diff --git a/.agents/skills/.npmignore b/.agents/skills/.npmignore
new file mode 100644
index 000000000..6524d100e
--- /dev/null
+++ b/.agents/skills/.npmignore
@@ -0,0 +1,10 @@
+# Internal-only assets — never ship to npm registry.
+# See script/package-layout-exclusion.test.ts for the enforcing guard.
+# Root .npmignore does not work for directories listed in package.json#files
+# under Bun 1.3.x, so the guard lives co-located with the published content.
+__*/
+__*.md
+.private/
+.draft/
+.private.md
+.draft.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 68b8792f1..31bd1c771 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -129,7 +129,7 @@ jobs:
test -f dist/index.d.ts || (echo "ERROR: dist/index.d.ts not found!" && exit 1)
- name: Verify dist bundle tests
- run: bun test src/shared/dist-bundle-bun-globals.test.ts
+ run: bun test src/shared/dist-bundle-bun-globals.test.ts src/shared/dist-bundle-prompt-content.test.ts
- name: Auto-commit schema changes
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
diff --git a/.opencode/command/.npmignore b/.opencode/command/.npmignore
new file mode 100644
index 000000000..6524d100e
--- /dev/null
+++ b/.opencode/command/.npmignore
@@ -0,0 +1,10 @@
+# Internal-only assets — never ship to npm registry.
+# See script/package-layout-exclusion.test.ts for the enforcing guard.
+# Root .npmignore does not work for directories listed in package.json#files
+# under Bun 1.3.x, so the guard lives co-located with the published content.
+__*/
+__*.md
+.private/
+.draft/
+.private.md
+.draft.md
diff --git a/.opencode/skills/.npmignore b/.opencode/skills/.npmignore
new file mode 100644
index 000000000..6524d100e
--- /dev/null
+++ b/.opencode/skills/.npmignore
@@ -0,0 +1,10 @@
+# Internal-only assets — never ship to npm registry.
+# See script/package-layout-exclusion.test.ts for the enforcing guard.
+# Root .npmignore does not work for directories listed in package.json#files
+# under Bun 1.3.x, so the guard lives co-located with the published content.
+__*/
+__*.md
+.private/
+.draft/
+.private.md
+.draft.md
diff --git a/script/package-layout-exclusion.test.ts b/script/package-layout-exclusion.test.ts
new file mode 100644
index 000000000..1fb0e37d3
--- /dev/null
+++ b/script/package-layout-exclusion.test.ts
@@ -0,0 +1,208 @@
+///
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test"
+import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
+import { dirname, join, relative, sep } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const repositoryRoot = fileURLToPath(new URL("..", import.meta.url))
+const packageJsonPath = join(repositoryRoot, "package.json")
+const fakeArtifactName = "__internal-fake-do-not-ship-test-artifact"
+const packageAssetRoots = [".opencode/command", ".opencode/skills", ".agents/command", ".agents/skills"] as const
+const fakeInternalSkillArtifactRootPaths = [
+ `.opencode/skills/${fakeArtifactName}`,
+ `.agents/skills/${fakeArtifactName}`,
+] as const
+const fakeInternalSkillArtifactPaths = [
+ `${fakeInternalSkillArtifactRootPaths[0]}/SKILL.md`,
+ `${fakeInternalSkillArtifactRootPaths[1]}/SKILL.md`,
+] as const
+const fakeInternalCommandArtifactPaths = [
+ `.opencode/command/${fakeArtifactName}.md`,
+ `.agents/command/${fakeArtifactName}.md`,
+] as const
+const fakeInternalArtifactCleanupPaths = [
+ ...fakeInternalSkillArtifactRootPaths,
+ ...fakeInternalCommandArtifactPaths,
+] as const
+
+let originalPackageJsonText: string | null = null
+let packageJsonWasTemporarilyModified = false
+
+class PackDryRunError extends Error {
+ constructor(readonly exitCode: number, readonly stderr: string) {
+ super(`bun pm pack --dry-run failed with exit code ${exitCode}: ${stderr}`)
+ this.name = "PackDryRunError"
+ }
+}
+
+class PackageFilesAnchorError extends Error {
+ constructor() {
+ super("package.json files list no longer contains the postinstall.mjs anchor")
+ this.name = "PackageFilesAnchorError"
+ }
+}
+
+function toPackagePath(filePath: string): string {
+ return relative(repositoryRoot, filePath).split(sep).join("/")
+}
+
+function collectPackagePathsRecursively(rootPath: string): string[] {
+ const collectedPaths: string[] = []
+ const directories = [rootPath]
+
+ while (directories.length > 0) {
+ const currentDirectory = directories.pop()
+ if (!currentDirectory) {
+ continue
+ }
+
+ for (const entry of readdirSync(currentDirectory, { withFileTypes: true })) {
+ const entryPath = join(currentDirectory, entry.name)
+ if (entry.isDirectory()) {
+ directories.push(entryPath)
+ continue
+ }
+
+ if (entry.isFile()) {
+ collectedPaths.push(toPackagePath(entryPath))
+ }
+ }
+ }
+
+ return collectedPaths
+}
+
+function parsePackedPaths(output: string): Set {
+ const packedPaths = new Set()
+ const packedPathPattern = /^packed\s+\S+\s+(.+)$/
+
+ for (const line of output.split("\n")) {
+ const match = packedPathPattern.exec(line)
+ const packedPath = match?.at(1)
+ if (packedPath) {
+ packedPaths.add(packedPath)
+ }
+ }
+
+ return packedPaths
+}
+
+async function packDryRunPaths(): Promise> {
+ const packProcess = Bun.spawn({
+ cmd: ["bun", "pm", "pack", "--dry-run"],
+ cwd: repositoryRoot,
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(packProcess.stdout).text(),
+ new Response(packProcess.stderr).text(),
+ packProcess.exited,
+ ])
+
+ if (exitCode !== 0) {
+ throw new PackDryRunError(exitCode, stderr)
+ }
+
+ return parsePackedPaths(stdout)
+}
+
+function withPackageAssetRoots(packageJsonText: string): string {
+ const missingAssetRoots = packageAssetRoots.filter((rootPath) => !packageJsonText.includes(`"${rootPath}"`))
+ if (missingAssetRoots.length === 0) {
+ return packageJsonText
+ }
+
+ const filesAnchor = ' "postinstall.mjs",\n'
+ if (!packageJsonText.includes(filesAnchor)) {
+ throw new PackageFilesAnchorError()
+ }
+
+ const insertedAssetRoots = missingAssetRoots.map((rootPath) => ` "${rootPath}",`).join("\n")
+ return packageJsonText.replace(filesAnchor, `${filesAnchor}${insertedAssetRoots}\n`)
+}
+
+function preparePackageJsonForDotAssetPacking(): void {
+ const packageJsonText = readFileSync(packageJsonPath, "utf8")
+ originalPackageJsonText = packageJsonText
+ const packageJsonTextWithAssetRoots = withPackageAssetRoots(packageJsonText)
+ packageJsonWasTemporarilyModified = packageJsonTextWithAssetRoots !== packageJsonText
+
+ if (packageJsonWasTemporarilyModified) {
+ writeFileSync(packageJsonPath, packageJsonTextWithAssetRoots)
+ }
+}
+
+function restorePackageJson(): void {
+ if (packageJsonWasTemporarilyModified && originalPackageJsonText !== null) {
+ writeFileSync(packageJsonPath, originalPackageJsonText)
+ }
+}
+
+function removeFakeInternalArtifacts(): void {
+ for (const packagePath of fakeInternalArtifactCleanupPaths) {
+ rmSync(join(repositoryRoot, packagePath), { recursive: true, force: true })
+ }
+}
+
+function writeFakeInternalArtifacts(packagePaths: readonly string[]): void {
+ for (const packagePath of packagePaths) {
+ const artifactPath = join(repositoryRoot, packagePath)
+ mkdirSync(dirname(artifactPath), { recursive: true })
+ writeFileSync(artifactPath, "# Fake internal artifact for package-layout-exclusion.test.ts\n")
+ }
+}
+
+function collectExistingFakeInternalSkillArtifactPaths(): string[] {
+ return fakeInternalSkillArtifactRootPaths
+ .filter((packagePath) => existsSync(join(repositoryRoot, packagePath)))
+ .flatMap((packagePath) => collectPackagePathsRecursively(join(repositoryRoot, packagePath)))
+ .sort()
+}
+
+describe("published package layout exclusions", () => {
+ beforeAll(() => {
+ removeFakeInternalArtifacts()
+
+ try {
+ preparePackageJsonForDotAssetPacking()
+ writeFakeInternalArtifacts([...fakeInternalSkillArtifactPaths, ...fakeInternalCommandArtifactPaths])
+ } catch (error) {
+ removeFakeInternalArtifacts()
+ restorePackageJson()
+ throw error
+ }
+ })
+
+ afterAll(() => {
+ removeFakeInternalArtifacts()
+ restorePackageJson()
+ })
+
+ test("#given internal-only skill assets #when packing package #then forbidden skill assets do not ship", async () => {
+ // given
+ expect(collectExistingFakeInternalSkillArtifactPaths()).toEqual(fakeInternalSkillArtifactPaths.toSorted())
+
+ // when
+ const packedPaths = await packDryRunPaths()
+
+ // then
+ const packedInternalSkillPaths = fakeInternalSkillArtifactPaths.filter((packagePath) => packedPaths.has(packagePath))
+ expect(packedInternalSkillPaths).toEqual([])
+ })
+
+ test("#given internal-only command assets #when packing package #then forbidden command assets do not ship", async () => {
+ // given
+ for (const packagePath of fakeInternalCommandArtifactPaths) {
+ expect(existsSync(join(repositoryRoot, packagePath))).toBe(true)
+ }
+
+ // when
+ const packedPaths = await packDryRunPaths()
+
+ // then
+ const packedInternalCommandPaths = fakeInternalCommandArtifactPaths.filter((packagePath) => packedPaths.has(packagePath))
+ expect(packedInternalCommandPaths).toEqual([])
+ })
+})
diff --git a/src/features/background-agent/parent-wake-assistant-blocking.test.ts b/src/features/background-agent/parent-wake-assistant-blocking.test.ts
index 8651eef73..fa855e635 100644
--- a/src/features/background-agent/parent-wake-assistant-blocking.test.ts
+++ b/src/features/background-agent/parent-wake-assistant-blocking.test.ts
@@ -1,5 +1,8 @@
+///
+
import { describe, expect, test } from "bun:test"
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
+import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import { ParentWakeNotifier } from "./parent-wake-notifier"
type PromptAsyncCall = {
@@ -16,12 +19,11 @@ type PromptAsyncCall = {
type ParentWakeClient = ConstructorParameters[0]["client"]
describe("ParentWakeNotifier — assistant turn blocking", () => {
- test("#given stale unfinished assistant text turn blocks the parent #when flushing pending wake #then stale tool escape does not dispatch", async () => {
+ test("#given stale unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake dispatches after defer max", async () => {
// given
const originalDateNow = Date.now
Date.now = () => 100_000
- const promptAsyncCalls: PromptAsyncCall[] = []
- const client: ParentWakeClient = {
+ const client = unsafeTestValue({
session: {
messages: async () => ({
data: [
@@ -31,17 +33,16 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
finish: "unknown",
time: { created: 90_000 },
},
- parts: [{ type: "reasoning", text: "still streaming" }],
+ parts: [{ type: "text", text: "still streaming" }],
},
],
}),
- status: async () => ({ data: { "parent-unfinished-text": { type: "idle" } } }),
- promptAsync: async (call: PromptAsyncCall) => {
- promptAsyncCalls.push(call)
+ status: async () => ({ data: { "parent-stale-text": { type: "idle" } } }),
+ promptAsync: async () => {
return { data: {} }
},
},
- }
+ })
const notifier = new ParentWakeNotifier(
{
client,
@@ -59,12 +60,12 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
},
)
notifier.queuePendingParentWake(
- "parent-unfinished-text",
+ "parent-stale-text",
"task complete",
{ agent: "sisyphus" },
true,
)
- const pendingWake = notifier.getPendingParentWakes().get("parent-unfinished-text")
+ const pendingWake = notifier.getPendingParentWakes().get("parent-stale-text")
expect(pendingWake).toBeDefined()
if (!pendingWake) {
throw new Error("Missing pending parent wake")
@@ -73,11 +74,76 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
try {
// when
- await notifier.flushPendingParentWake("parent-unfinished-text")
+ const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-stale-text", pendingWake)
// then
- expect(promptAsyncCalls).toHaveLength(0)
- expect(notifier.getPendingParentWakes().has("parent-unfinished-text")).toBe(true)
+ expect(decision).toEqual({ defer: false, skipPromptGateToolStateCheck: false })
+ } finally {
+ Date.now = originalDateNow
+ notifier.shutdown()
+ releaseAllPromptAsyncReservationsForTesting()
+ }
+ })
+
+ test("#given fresh unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake continues deferring", async () => {
+ // given
+ const originalDateNow = Date.now
+ Date.now = () => 100_000
+ const client = unsafeTestValue({
+ session: {
+ messages: async () => ({
+ data: [
+ {
+ info: {
+ role: "assistant",
+ finish: "unknown",
+ time: { created: 99_000 },
+ },
+ parts: [{ type: "text", text: "still streaming" }],
+ },
+ ],
+ }),
+ status: async () => ({ data: { "parent-fresh-text": { type: "idle" } } }),
+ promptAsync: async () => {
+ return { data: {} }
+ },
+ },
+ })
+ const notifier = new ParentWakeNotifier(
+ {
+ client,
+ directory: "/tmp/test-omo",
+ enqueueNotificationForParent: async (_sessionID, operation) => {
+ await operation()
+ },
+ },
+ {
+ pendingRetryMs: 1_000,
+ acceptedMessageSkewMs: 5_000,
+ toolCallDeferMaxMs: 5_000,
+ failureRequeueWindowMs: 5_000,
+ userMessageInProgressWindowMs: 2_000,
+ },
+ )
+ notifier.queuePendingParentWake(
+ "parent-fresh-text",
+ "task complete",
+ { agent: "sisyphus" },
+ true,
+ )
+ const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-text")
+ expect(pendingWake).toBeDefined()
+ if (!pendingWake) {
+ throw new Error("Missing pending parent wake")
+ }
+ pendingWake.toolCallDeferralStartedAt = 98_000
+
+ try {
+ // when
+ const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-text", pendingWake)
+
+ // then
+ expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false })
} finally {
Date.now = originalDateNow
notifier.shutdown()
@@ -89,7 +155,7 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
// given
const promptAsyncCalls: PromptAsyncCall[] = []
let messageReads = 0
- const client: ParentWakeClient = {
+ const client = unsafeTestValue({
session: {
messages: async () => {
messageReads += 1
@@ -115,7 +181,7 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
return { data: {} }
},
},
- }
+ })
const notifier = new ParentWakeNotifier(
{
client,
diff --git a/src/features/background-agent/parent-wake-notifier.ts b/src/features/background-agent/parent-wake-notifier.ts
index eeaa7f683..b16cf0a5a 100644
--- a/src/features/background-agent/parent-wake-notifier.ts
+++ b/src/features/background-agent/parent-wake-notifier.ts
@@ -577,10 +577,11 @@ export class ParentWakeNotifier {
const latestToolWaitAgeMs = toolWaitState.createdAt === undefined
? 0
: now - toolWaitState.createdAt
+ const deferAge = now - wake.toolCallDeferralStartedAt
if (
wake.shouldReply
&& toolWaitState.waiting
- && now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs
+ && deferAge >= this.options.toolCallDeferMaxMs
&& latestToolWaitAgeMs >= this.options.toolCallDeferMaxMs
) {
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
@@ -588,6 +589,13 @@ export class ParentWakeNotifier {
})
return { defer: false, skipPromptGateToolStateCheck: true }
}
+ if (!toolWaitState.waiting && deferAge >= this.options.toolCallDeferMaxMs) {
+ log("[background-agent] Sending parent wake after stale assistant-text deferral window:", {
+ sessionID,
+ deferAgeMs: deferAge,
+ })
+ return { defer: false, skipPromptGateToolStateCheck: false }
+ }
log("[background-agent] Deferred parent wake because latest assistant turn blocks internal prompts:", {
sessionID,
})
diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts
index bd7fffcf0..4b54b7a3c 100644
--- a/src/hooks/ralph-loop/loop-state-controller.ts
+++ b/src/hooks/ralph-loop/loop-state-controller.ts
@@ -45,6 +45,7 @@ export function createLoopStateController(options: {
completion_promise: initialCompletionPromise,
initial_completion_promise: initialCompletionPromise,
verification_attempt_id: undefined,
+ verification_attempt_started_at: undefined,
verification_session_id: undefined,
ultrawork: loopOptions?.ultrawork,
verification_pending: undefined,
@@ -139,6 +140,7 @@ export function createLoopStateController(options: {
state.verification_pending = true
state.completion_promise = ULTRAWORK_VERIFICATION_PROMISE
state.verification_attempt_id = undefined
+ state.verification_attempt_started_at = undefined
state.verification_session_id = undefined
state.initial_completion_promise ??= DEFAULT_COMPLETION_PROMISE
@@ -156,6 +158,7 @@ export function createLoopStateController(options: {
}
state.verification_session_id = verificationSessionID
+ state.verification_attempt_started_at = undefined
if (!writeState(directory, state, stateDir)) {
return null
@@ -175,6 +178,7 @@ export function createLoopStateController(options: {
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
state.verification_pending = undefined
state.verification_attempt_id = undefined
+ state.verification_attempt_started_at = undefined
state.verification_session_id = undefined
if (typeof messageCountAtStart === "number") {
state.message_count_at_start = messageCountAtStart
@@ -197,6 +201,7 @@ export function createLoopStateController(options: {
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
state.verification_pending = undefined
state.verification_attempt_id = undefined
+ state.verification_attempt_started_at = undefined
state.verification_session_id = undefined
if (typeof messageCountAtStart === "number") {
state.message_count_at_start = messageCountAtStart
diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts
index 5065f2dd1..c093496dc 100644
--- a/src/hooks/ralph-loop/pending-verification-handler.ts
+++ b/src/hooks/ralph-loop/pending-verification-handler.ts
@@ -7,6 +7,8 @@ import { handleFailedVerification } from "./verification-failure-handler"
import { withTimeout } from "./with-timeout"
import type { IterationCommitExpectation } from "./types"
+export const STUCK_VERIFICATION_TIMEOUT_MS = 30 * 60 * 1000
+
type OpenCodeSessionMessage = {
info?: { role?: string }
parts?: Array<{ type?: string; text?: string }>
@@ -138,12 +140,25 @@ export async function handlePendingVerification(
}
if (state.verification_attempt_id && !state.verification_session_id) {
- log(`[${HOOK_NAME}] Skipped verification failure: oracle dispatch in flight`, {
- sessionID,
- verificationAttemptId: state.verification_attempt_id,
- iteration: state.iteration,
- })
- return
+ const startedAt = state.verification_attempt_started_at
+ const attemptAgeMs = startedAt !== undefined ? Date.now() - startedAt : undefined
+ const isStuck = attemptAgeMs !== undefined && attemptAgeMs > STUCK_VERIFICATION_TIMEOUT_MS
+
+ if (isStuck) {
+ log(`[${HOOK_NAME}] Stuck oracle dispatch detected, proceeding to failure handler`, {
+ sessionID,
+ verificationAttemptId: state.verification_attempt_id,
+ attemptAgeMs,
+ iteration: state.iteration,
+ })
+ } else {
+ log(`[${HOOK_NAME}] Skipped verification failure: oracle dispatch in flight`, {
+ sessionID,
+ verificationAttemptId: state.verification_attempt_id,
+ iteration: state.iteration,
+ })
+ return
+ }
}
const restarted = await handleFailedVerification(ctx, {
diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts
index f5ca06fe2..346128975 100644
--- a/src/hooks/ralph-loop/storage.ts
+++ b/src/hooks/ralph-loop/storage.ts
@@ -41,6 +41,7 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
}
const ultrawork = data.ultrawork === true || data.ultrawork === "true" ? true : undefined
+ const verificationAttemptStartedAt = Number(data.verification_attempt_started_at)
const maxIterations =
data.max_iterations === undefined || data.max_iterations === ""
? ultrawork
@@ -65,6 +66,12 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
verification_attempt_id: data.verification_attempt_id
? stripQuotes(data.verification_attempt_id)
: undefined,
+ verification_attempt_started_at:
+ data.verification_attempt_started_at === undefined || data.verification_attempt_started_at === ""
+ ? undefined
+ : Number.isFinite(verificationAttemptStartedAt)
+ ? verificationAttemptStartedAt
+ : undefined,
verification_session_id: data.verification_session_id
? stripQuotes(data.verification_session_id)
: undefined,
@@ -106,9 +113,19 @@ export function writeState(
const initialCompletionPromiseLine = state.initial_completion_promise
? `initial_completion_promise: "${state.initial_completion_promise}"\n`
: ""
+ const existingState = readState(directory, customPath)
+ const verificationAttemptStartedAt = state.verification_session_id || !state.verification_attempt_id
+ ? undefined
+ : state.verification_attempt_started_at
+ ?? (existingState?.verification_attempt_id !== state.verification_attempt_id
+ ? Date.now()
+ : existingState.verification_attempt_started_at)
const verificationAttemptLine = state.verification_attempt_id
? `verification_attempt_id: "${state.verification_attempt_id}"\n`
: ""
+ const verificationAttemptStartedAtLine = typeof verificationAttemptStartedAt === "number"
+ ? `verification_attempt_started_at: ${verificationAttemptStartedAt}\n`
+ : ""
const verificationSessionLine = state.verification_session_id
? `verification_session_id: "${state.verification_session_id}"\n`
: ""
@@ -124,7 +141,7 @@ export function writeState(
active: ${state.active}
iteration: ${state.iteration}
${maxIterationsLine}completion_promise: "${state.completion_promise}"
-${initialCompletionPromiseLine}${verificationAttemptLine}${verificationSessionLine}started_at: "${state.started_at}"
+${initialCompletionPromiseLine}${verificationAttemptLine}${verificationAttemptStartedAtLine}${verificationSessionLine}started_at: "${state.started_at}"
${sessionIdLine}${ultraworkLine}${verificationPendingLine}${strategyLine}${messageCountAtStartLine}---
${state.prompt}
`
diff --git a/src/hooks/ralph-loop/stuck-oracle-dispatch-recovery.test.ts b/src/hooks/ralph-loop/stuck-oracle-dispatch-recovery.test.ts
new file mode 100644
index 000000000..c9ac2911b
--- /dev/null
+++ b/src/hooks/ralph-loop/stuck-oracle-dispatch-recovery.test.ts
@@ -0,0 +1,138 @@
+import type { PluginInput } from "@opencode-ai/plugin"
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
+import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
+import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
+import { handlePendingVerification, STUCK_VERIFICATION_TIMEOUT_MS } from "./pending-verification-handler"
+import type { RalphLoopState } from "./types"
+
+const NOW_MS = 1_800_000_000_000
+
+type PendingVerificationInput = Parameters[1]
+type LoopStateController = PendingVerificationInput["loopState"]
+
+function createState(verificationAttemptStartedAt?: number): RalphLoopState {
+ const state: RalphLoopState = {
+ active: true,
+ iteration: 2,
+ completion_promise: "",
+ initial_completion_promise: "DONE",
+ started_at: "2026-01-01T00:00:00.000Z",
+ prompt: "Ship release blockers",
+ session_id: "session-123",
+ ultrawork: true,
+ verification_pending: true,
+ verification_attempt_id: "attempt-123",
+ }
+
+ if (verificationAttemptStartedAt === undefined) {
+ return state
+ }
+
+ return {
+ ...state,
+ verification_attempt_started_at: verificationAttemptStartedAt,
+ }
+}
+
+function createPluginInput(promptCalls: string[]): PluginInput {
+ return unsafeTestValue({
+ client: {
+ session: {
+ messages: async () => ({ data: [] }),
+ promptAsync: async (input: unknown) => {
+ promptCalls.push(JSON.stringify(input) ?? "")
+ return {}
+ },
+ abort: async () => ({}),
+ },
+ tui: {
+ showToast: async () => ({}),
+ },
+ },
+ directory: "/tmp/ralph-loop-stuck-oracle-test",
+ })
+}
+
+function createLoopStateController(state: RalphLoopState) {
+ const clearVerificationState = mock(() => state)
+ const incrementIteration = mock(() => state)
+ const loopState = {
+ restartAfterFailedVerification: mock(() => null),
+ clearVerificationState,
+ incrementIteration,
+ clear: mock(() => true),
+ setVerificationSessionID: mock(() => null),
+ } satisfies LoopStateController
+
+ return { loopState, clearVerificationState, incrementIteration }
+}
+
+async function runPendingVerification(state: RalphLoopState, loopState: LoopStateController, promptCalls: string[]) {
+ await handlePendingVerification(createPluginInput(promptCalls), {
+ sessionID: "session-123",
+ state,
+ matchesParentSession: true,
+ matchesVerificationSession: false,
+ loopState,
+ directory: "/tmp/ralph-loop-stuck-oracle-test",
+ apiTimeoutMs: 100,
+ })
+}
+
+describe("ralph-loop stuck oracle dispatch recovery", () => {
+ const realDateNow = Date.now
+
+ beforeEach(() => {
+ Date.now = () => NOW_MS
+ })
+
+ afterEach(() => {
+ Date.now = realDateNow
+ releaseAllPromptAsyncReservationsForTesting()
+ })
+
+ test("#given verification attempt is recent and no verification session exists #when pending verification is handled #then handler returns early", async () => {
+ // given
+ const promptCalls: string[] = []
+ const state = createState(NOW_MS - 1_000)
+ const { loopState, clearVerificationState, incrementIteration } = createLoopStateController(state)
+
+ // when
+ await runPendingVerification(state, loopState, promptCalls)
+
+ // then
+ expect(promptCalls).toHaveLength(0)
+ expect(clearVerificationState).not.toHaveBeenCalled()
+ expect(incrementIteration).not.toHaveBeenCalled()
+ })
+
+ test("#given verification attempt is older than stuck timeout and no verification session exists #when pending verification is handled #then handler proceeds to failed verification recovery", async () => {
+ // given
+ const promptCalls: string[] = []
+ const state = createState(NOW_MS - STUCK_VERIFICATION_TIMEOUT_MS - 1)
+ const { loopState, clearVerificationState, incrementIteration } = createLoopStateController(state)
+
+ // when
+ await runPendingVerification(state, loopState, promptCalls)
+
+ // then
+ expect(promptCalls).toHaveLength(1)
+ expect(clearVerificationState).toHaveBeenCalledTimes(1)
+ expect(incrementIteration).toHaveBeenCalledTimes(1)
+ })
+
+ test("#given legacy verification attempt has no start timestamp and no verification session exists #when pending verification is handled #then handler returns early", async () => {
+ // given
+ const promptCalls: string[] = []
+ const state = createState()
+ const { loopState, clearVerificationState, incrementIteration } = createLoopStateController(state)
+
+ // when
+ await runPendingVerification(state, loopState, promptCalls)
+
+ // then
+ expect(promptCalls).toHaveLength(0)
+ expect(clearVerificationState).not.toHaveBeenCalled()
+ expect(incrementIteration).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts
index 8c0106b05..00b887439 100644
--- a/src/hooks/ralph-loop/types.ts
+++ b/src/hooks/ralph-loop/types.ts
@@ -8,6 +8,7 @@ export interface RalphLoopState {
completion_promise: string
initial_completion_promise?: string
verification_attempt_id?: string
+ verification_attempt_started_at?: number
verification_session_id?: string
started_at: string
prompt: string
diff --git a/src/hooks/runtime-fallback/error-classifier.test.ts b/src/hooks/runtime-fallback/error-classifier.test.ts
index fb311990e..fca01b901 100644
--- a/src/hooks/runtime-fallback/error-classifier.test.ts
+++ b/src/hooks/runtime-fallback/error-classifier.test.ts
@@ -111,6 +111,83 @@ describe("runtime-fallback error classifier", () => {
expect(retryable).toBe(true)
})
+ test("isRetryableError REJECTS isRetryable=true when status code is 401 Unauthorized", () => {
+ //#given
+ const error = { error: { statusCode: 401, isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(false)
+ })
+
+ test("isRetryableError REJECTS isRetryable=true when status code is 403 Forbidden", () => {
+ //#given
+ const error = { error: { statusCode: 403, isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(false)
+ })
+
+ test("isRetryableError REJECTS isRetryable=true when status code is 404 Not Found", () => {
+ //#given
+ const error = { error: { statusCode: 404, isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(false)
+ })
+
+ test("isRetryableError HONORS isRetryable=true when status code is 429 (rate-limit)", () => {
+ //#given
+ const error = { error: { statusCode: 429, isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(true)
+ })
+
+ test("isRetryableError HONORS isRetryable=true when status code is 503 (service unavailable)", () => {
+ //#given
+ const error = { error: { statusCode: 503, isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(true)
+ })
+
+ test("isRetryableError HONORS isRetryable=true when no status code is present (pure network error)", () => {
+ //#given
+ const error = { error: { isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(true)
+ })
+
+ test("isRetryableError HONORS isRetryable=true when status code is in retryOnErrors list", () => {
+ //#given
+ const error = { error: { statusCode: 400, isRetryable: true } }
+
+ //#when
+ const retryable = isRetryableError(error, [400, 429, 503, 529])
+
+ //#then
+ expect(retryable).toBe(true)
+ })
+
test("ignores malformed retryable flags on otherwise non-retryable errors", () => {
//#given
const error = {
diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts
index 76c970e81..4595f020f 100644
--- a/src/hooks/runtime-fallback/error-classifier.ts
+++ b/src/hooks/runtime-fallback/error-classifier.ts
@@ -1,4 +1,5 @@
-import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants"
+import { DEFAULT_CONFIG, HOOK_NAME, RETRYABLE_ERROR_PATTERNS } from "./constants"
+import { log } from "../../shared/logger"
export { extractAutoRetrySignal } from "./auto-retry-signal"
@@ -119,6 +120,10 @@ export function extractRetryableSignal(error: unknown): boolean | undefined {
return undefined
}
+function isStatusCodeRetrySafe(code: number, retryOnErrors: number[]): boolean {
+ return retryOnErrors.includes(code) || (code >= 500 && code < 600) || code === 408 || code === 425 || code === 429
+}
+
function isLocalizedQuotaExhaustionMessage(message: string): boolean {
return (
(/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) ||
@@ -221,8 +226,16 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
return true
}
- if (extractRetryableSignal(error) === true) {
- return true
+ const retryableSignal = extractRetryableSignal(error)
+ if (retryableSignal === true) {
+ if (statusCode === undefined || isStatusCodeRetrySafe(statusCode, retryOnErrors)) {
+ return true
+ }
+
+ log(`[${HOOK_NAME}] Retryable signal rejected due to unsafe status code`, {
+ statusCode,
+ retryOnErrors,
+ })
}
return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
diff --git a/src/shared/dist-bundle-prompt-content.test.ts b/src/shared/dist-bundle-prompt-content.test.ts
new file mode 100644
index 000000000..ced4a1074
--- /dev/null
+++ b/src/shared/dist-bundle-prompt-content.test.ts
@@ -0,0 +1,103 @@
+///
+
+import { describe, expect, test } from "bun:test"
+
+const DIST_INDEX = "dist/index.js"
+const SKIP_MESSAGE = "[skipped - dist not built]"
+
+const PROMPT_SIGNATURES = [
+ {
+ path: "packages/prompts-core/prompts/ultrawork/default.md",
+ label: "Ultrawork default",
+ signature: "ULTRAWORK MODE ENABLED!",
+ },
+ {
+ path: "packages/prompts-core/prompts/ultrawork/gemini.md",
+ label: "Ultrawork Gemini",
+ signature: "ULTRAWORK MODE ENABLED!",
+ },
+ {
+ path: "packages/prompts-core/prompts/ultrawork/gpt.md",
+ label: "Ultrawork GPT",
+ signature: "ULTRAWORK MODE ENABLED!",
+ },
+ {
+ path: "packages/prompts-core/prompts/atlas/default.md",
+ label: "Atlas default",
+ signature: "You are Atlas - the Master Orchestrator from OhMyOpenCode.",
+ },
+ {
+ path: "packages/prompts-core/prompts/atlas/gemini.md",
+ label: "Atlas Gemini",
+ signature: "Your value is ORCHESTRATION, not coding.",
+ },
+ {
+ path: "packages/prompts-core/prompts/atlas/gpt.md",
+ label: "Atlas GPT",
+ signature: "This prompt is outcome-first. Choose the most efficient path to the outcomes above.",
+ },
+ {
+ path: "packages/prompts-core/prompts/atlas/kimi.md",
+ label: "Atlas Kimi",
+ signature: "Trust the trained prior on the hard 30% (verification reasoning, failure diagnosis, dependency analysis).",
+ },
+ {
+ path: "packages/prompts-core/prompts/atlas/opus-4-7.md",
+ label: "Atlas Opus 4.7",
+ signature: "Opus 4.7 spawns fewer subagents than Opus 4.6 unless told otherwise.",
+ },
+ {
+ path: "packages/prompts-core/prompts/prometheus/default.md",
+ label: "Prometheus default",
+ signature: "YOU ARE A PLANNER. YOU ARE NOT AN IMPLEMENTER. YOU DO NOT WRITE CODE. YOU DO NOT EXECUTE TASKS.",
+ },
+ {
+ path: "packages/prompts-core/prompts/prometheus/gemini.md",
+ label: "Prometheus Gemini",
+ signature: "If you feel the urge to write code or implement something - STOP. That is NOT your job.",
+ },
+ {
+ path: "packages/prompts-core/prompts/prometheus/gpt.md",
+ label: "Prometheus GPT",
+ signature: "YOU ARE A PLANNER. NOT AN IMPLEMENTER. NOT A CODE WRITER.",
+ },
+ {
+ path: "packages/prompts-core/prompts/mode/search.md",
+ label: "Search mode",
+ signature: "MAXIMIZE SEARCH EFFORT. Launch multiple background agents IN PARALLEL:",
+ },
+ {
+ path: "packages/prompts-core/prompts/mode/analyze.md",
+ label: "Analyze mode",
+ signature: "IF COMPLEX - DO NOT STRUGGLE ALONE. Consult specialists:",
+ },
+ {
+ path: "packages/prompts-core/prompts/mode/team.md",
+ label: "Team mode",
+ signature: "Team-mode reference detected. Orchestrate via team_* tools",
+ },
+ {
+ path: "packages/prompts-core/prompts/mode/hyperplan.md",
+ label: "Hyperplan mode",
+ signature: "HYPERPLAN MODE ENABLED!",
+ },
+] as const
+
+describe("dist bundle prompt content", () => {
+ test("#given dist bundle #when scanned #then markdown prompt signatures are inlined", async () => {
+ const distIndex = Bun.file(DIST_INDEX)
+ if (!(await distIndex.exists())) {
+ console.info(SKIP_MESSAGE)
+ return
+ }
+
+ const bundle = await distIndex.text()
+
+ for (const prompt of PROMPT_SIGNATURES) {
+ expect(
+ bundle.includes(prompt.signature),
+ `${prompt.label} prompt content missing from dist/index.js (${prompt.path}): markdown inlining may have regressed`,
+ ).toBe(true)
+ }
+ })
+})