From f05e0cbe99bc08d575d8fe00fd96ca6e7f540bfb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 01:32:23 +0900 Subject: [PATCH 1/5] fix(runtime-fallback): gate retryable signal on status-code allowlist extractRetryableSignal returns the raw isRetryable hint from up to 5 nested AI SDK error paths. isRetryableError previously trusted any true result blindly, which would burn every configured fallback model in an infinite loop if a provider mis-tagged a 401, 403, or other non-transient 4xx as retryable. Honor the signal only when the status code is absent, in the configured retry_on_errors list, in 5xx, or in {408, 425, 429}. Reject the signal when the status code is a non-transient 4xx and log the rejection so operators can debug provider mis-classifications. Closes pre-publish blocker V8. --- .../runtime-fallback/error-classifier.test.ts | 77 +++++++++++++++++++ .../runtime-fallback/error-classifier.ts | 19 ++++- 2 files changed, 93 insertions(+), 3 deletions(-) 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)) From 69c955f61fd8e2d3fda1cadd350099f0d3a226aa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 01:32:35 +0900 Subject: [PATCH 2/5] fix(parent-wake): bound assistant-text defer to escape stuck sessions shouldDeferParentWakeForSessionHistory previously had only one escape path from the defer state: stale pending tool call. If the assistant had unfinished text but no pending tool call (session crashed mid-stream, model errored after partial text, network died), the escape never fired and parent-wake deferred forever. Background-agent completions never woke the parent. Add a second escape: when the assistant text blocks but no tool wait is pending, dispatch the wake after toolCallDeferMaxMs anyway. The prompt-async-gate still defends if the assistant text turns out to be live; we just stop deferring indefinitely. Closes pre-publish blocker V11. --- .../parent-wake-assistant-blocking.test.ts | 96 ++++++++++++++++--- .../background-agent/parent-wake-notifier.ts | 10 +- 2 files changed, 90 insertions(+), 16 deletions(-) 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 2cf97b413..1f7fe5d07 100644 --- a/src/features/background-agent/parent-wake-notifier.ts +++ b/src/features/background-agent/parent-wake-notifier.ts @@ -560,10 +560,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:", { @@ -571,6 +572,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, }) From 14b3523af6336937be80db104c0ab5805649605a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 01:32:49 +0900 Subject: [PATCH 3/5] fix(ralph-loop): time-bound oracle dispatch wait to prevent stall The double-fire race fix (#4256) had handlePendingVerification return early when verification_attempt_id was set but verification_session_id was not, since the oracle dispatch is still in flight. That guard introduced a permanent-stall failure mode: if tool-execute-after never runs (oracle session hangs, crashes, OOM-killed, or tmux killed externally), verification_session_id stays undefined forever and the ralph-loop never escapes the pending-verification state. Track verification_attempt_started_at as a state field, clear it on restart/clear/setVerificationSessionID, and fall through to handleFailedVerification when the attempt has been pending past STUCK_VERIFICATION_TIMEOUT_MS (30 minutes). Legacy persisted states without the timestamp continue to defer (no timeout to evaluate), matching pre-fix behavior for that edge case. Closes pre-publish blocker V25. --- src/hooks/ralph-loop/loop-state-controller.ts | 5 + .../pending-verification-handler.ts | 27 +++- src/hooks/ralph-loop/storage.ts | 19 ++- .../stuck-oracle-dispatch-recovery.test.ts | 138 ++++++++++++++++++ src/hooks/ralph-loop/types.ts | 1 + 5 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 src/hooks/ralph-loop/stuck-oracle-dispatch-recovery.test.ts 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 From 3e0a975d1ff209f2500f306237017cfa03ba12f0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 01:33:06 +0900 Subject: [PATCH 4/5] test(dist-bundle): assert inlined prompt content survives bundling After the prompts-core migration the TypeScript prompt sources were deleted; the only mechanism delivering markdown prompts to npm users is bun build inlining via bunfig.toml [loader] ".md" = "text" and import attributes. bun test runs from src/index.ts, not from dist/index.js, so a future Bun upgrade that silently regresses markdown inlining would pass source tests green while the published bundle is broken with Cannot find module ../prompts/atlas/default.md at first agent load. Add a smoke test that scans the built dist/index.js for unique signature strings from each migrated prompt file (15 signatures: 3 ultrawork + 5 atlas + 3 prometheus + 4 mode prompts). Skips gracefully if dist/index.js does not exist (local bun test before build). Wire into the existing CI Verify dist bundle tests step in .github/workflows/ci.yml so the regression catches in CI build. Closes pre-publish blocker V1 and V33. --- .github/workflows/ci.yml | 2 +- src/shared/dist-bundle-prompt-content.test.ts | 103 ++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/shared/dist-bundle-prompt-content.test.ts 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/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) + } + }) +}) From 8e28e29c26773013f0d36706d0caaaa1f7cc1f96 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 26 May 2026 16:29:47 +0900 Subject: [PATCH 5/5] fix(package): block internal-only assets from publish payload Validator finding #12 from the publish-debate-vortex hyperultradebate flagged that internal-only skill and command assets could leak into the npm payload once the dot asset roots are included. Bun 1.3.x ignores a root .npmignore for directories listed in package.json#files, so the exclusion rules live in nested .npmignore files co-located with each published command and skill directory. RED before nested ignores: bun test script/package-layout-exclusion.test.ts failed with expect(received).toEqual(expected), receiving .opencode/skills/__internal-fake-do-not-ship-test-artifact/SKILL.md, .agents/skills/__internal-fake-do-not-ship-test-artifact/SKILL.md, .opencode/command/__internal-fake-do-not-ship-test-artifact.md, and .agents/command/__internal-fake-do-not-ship-test-artifact.md instead of []. GREEN after nested ignores: bun test script/package-layout-exclusion.test.ts reported 2 pass, 0 fail, 5 expect() calls. This is the exclusion companion to script/package-layout.test.ts, the inclusion test arriving through the dev merge. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .agents/command/.npmignore | 10 ++ .agents/skills/.npmignore | 10 ++ .opencode/command/.npmignore | 10 ++ .opencode/skills/.npmignore | 10 ++ script/package-layout-exclusion.test.ts | 208 ++++++++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 .agents/command/.npmignore create mode 100644 .agents/skills/.npmignore create mode 100644 .opencode/command/.npmignore create mode 100644 .opencode/skills/.npmignore create mode 100644 script/package-layout-exclusion.test.ts 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/.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([]) + }) +})