Merge branch 'fix/pre-publish-blockers-v4.5.0'
Pre-publish blockers for v4.5.0: - fix(runtime-fallback): gate retryable signal on status-code allowlist (f05e0cbe9) - fix(parent-wake): bound assistant-text defer to escape stuck sessions (69c955f61) - fix(ralph-loop): time-bound oracle dispatch wait to prevent stall (14b3523af) - test(dist-bundle): assert inlined prompt content survives bundling (3e0a975d1) - fix(package): block internal-only assets from publish payload (8e28e29c2) Verified via publish-debate-vortex hyperultradebate (6 hostile agents, 3 rounds). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
test -f dist/index.d.ts || (echo "ERROR: dist/index.d.ts not found!" && exit 1)
|
test -f dist/index.d.ts || (echo "ERROR: dist/index.d.ts not found!" && exit 1)
|
||||||
|
|
||||||
- name: Verify dist bundle tests
|
- 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
|
- name: Auto-commit schema changes
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
const packedPaths = new Set<string>()
|
||||||
|
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<Set<string>> {
|
||||||
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
|
||||||
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
import { ParentWakeNotifier } from "./parent-wake-notifier"
|
import { ParentWakeNotifier } from "./parent-wake-notifier"
|
||||||
|
|
||||||
type PromptAsyncCall = {
|
type PromptAsyncCall = {
|
||||||
@@ -16,12 +19,11 @@ type PromptAsyncCall = {
|
|||||||
type ParentWakeClient = ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
type ParentWakeClient = ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
||||||
|
|
||||||
describe("ParentWakeNotifier — assistant turn blocking", () => {
|
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
|
// given
|
||||||
const originalDateNow = Date.now
|
const originalDateNow = Date.now
|
||||||
Date.now = () => 100_000
|
Date.now = () => 100_000
|
||||||
const promptAsyncCalls: PromptAsyncCall[] = []
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
const client: ParentWakeClient = {
|
|
||||||
session: {
|
session: {
|
||||||
messages: async () => ({
|
messages: async () => ({
|
||||||
data: [
|
data: [
|
||||||
@@ -31,17 +33,16 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
|
|||||||
finish: "unknown",
|
finish: "unknown",
|
||||||
time: { created: 90_000 },
|
time: { created: 90_000 },
|
||||||
},
|
},
|
||||||
parts: [{ type: "reasoning", text: "still streaming" }],
|
parts: [{ type: "text", text: "still streaming" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
status: async () => ({ data: { "parent-unfinished-text": { type: "idle" } } }),
|
status: async () => ({ data: { "parent-stale-text": { type: "idle" } } }),
|
||||||
promptAsync: async (call: PromptAsyncCall) => {
|
promptAsync: async () => {
|
||||||
promptAsyncCalls.push(call)
|
|
||||||
return { data: {} }
|
return { data: {} }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
const notifier = new ParentWakeNotifier(
|
const notifier = new ParentWakeNotifier(
|
||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
@@ -59,12 +60,12 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
notifier.queuePendingParentWake(
|
notifier.queuePendingParentWake(
|
||||||
"parent-unfinished-text",
|
"parent-stale-text",
|
||||||
"task complete",
|
"task complete",
|
||||||
{ agent: "sisyphus" },
|
{ agent: "sisyphus" },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
const pendingWake = notifier.getPendingParentWakes().get("parent-unfinished-text")
|
const pendingWake = notifier.getPendingParentWakes().get("parent-stale-text")
|
||||||
expect(pendingWake).toBeDefined()
|
expect(pendingWake).toBeDefined()
|
||||||
if (!pendingWake) {
|
if (!pendingWake) {
|
||||||
throw new Error("Missing pending parent wake")
|
throw new Error("Missing pending parent wake")
|
||||||
@@ -73,11 +74,76 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// when
|
// when
|
||||||
await notifier.flushPendingParentWake("parent-unfinished-text")
|
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-stale-text", pendingWake)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(promptAsyncCalls).toHaveLength(0)
|
expect(decision).toEqual({ defer: false, skipPromptGateToolStateCheck: false })
|
||||||
expect(notifier.getPendingParentWakes().has("parent-unfinished-text")).toBe(true)
|
} 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<ParentWakeClient>({
|
||||||
|
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 {
|
} finally {
|
||||||
Date.now = originalDateNow
|
Date.now = originalDateNow
|
||||||
notifier.shutdown()
|
notifier.shutdown()
|
||||||
@@ -89,7 +155,7 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
|
|||||||
// given
|
// given
|
||||||
const promptAsyncCalls: PromptAsyncCall[] = []
|
const promptAsyncCalls: PromptAsyncCall[] = []
|
||||||
let messageReads = 0
|
let messageReads = 0
|
||||||
const client: ParentWakeClient = {
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
session: {
|
session: {
|
||||||
messages: async () => {
|
messages: async () => {
|
||||||
messageReads += 1
|
messageReads += 1
|
||||||
@@ -115,7 +181,7 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
|
|||||||
return { data: {} }
|
return { data: {} }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
const notifier = new ParentWakeNotifier(
|
const notifier = new ParentWakeNotifier(
|
||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -577,10 +577,11 @@ export class ParentWakeNotifier {
|
|||||||
const latestToolWaitAgeMs = toolWaitState.createdAt === undefined
|
const latestToolWaitAgeMs = toolWaitState.createdAt === undefined
|
||||||
? 0
|
? 0
|
||||||
: now - toolWaitState.createdAt
|
: now - toolWaitState.createdAt
|
||||||
|
const deferAge = now - wake.toolCallDeferralStartedAt
|
||||||
if (
|
if (
|
||||||
wake.shouldReply
|
wake.shouldReply
|
||||||
&& toolWaitState.waiting
|
&& toolWaitState.waiting
|
||||||
&& now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs
|
&& deferAge >= this.options.toolCallDeferMaxMs
|
||||||
&& latestToolWaitAgeMs >= this.options.toolCallDeferMaxMs
|
&& latestToolWaitAgeMs >= this.options.toolCallDeferMaxMs
|
||||||
) {
|
) {
|
||||||
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
|
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
|
||||||
@@ -588,6 +589,13 @@ export class ParentWakeNotifier {
|
|||||||
})
|
})
|
||||||
return { defer: false, skipPromptGateToolStateCheck: true }
|
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:", {
|
log("[background-agent] Deferred parent wake because latest assistant turn blocks internal prompts:", {
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function createLoopStateController(options: {
|
|||||||
completion_promise: initialCompletionPromise,
|
completion_promise: initialCompletionPromise,
|
||||||
initial_completion_promise: initialCompletionPromise,
|
initial_completion_promise: initialCompletionPromise,
|
||||||
verification_attempt_id: undefined,
|
verification_attempt_id: undefined,
|
||||||
|
verification_attempt_started_at: undefined,
|
||||||
verification_session_id: undefined,
|
verification_session_id: undefined,
|
||||||
ultrawork: loopOptions?.ultrawork,
|
ultrawork: loopOptions?.ultrawork,
|
||||||
verification_pending: undefined,
|
verification_pending: undefined,
|
||||||
@@ -139,6 +140,7 @@ export function createLoopStateController(options: {
|
|||||||
state.verification_pending = true
|
state.verification_pending = true
|
||||||
state.completion_promise = ULTRAWORK_VERIFICATION_PROMISE
|
state.completion_promise = ULTRAWORK_VERIFICATION_PROMISE
|
||||||
state.verification_attempt_id = undefined
|
state.verification_attempt_id = undefined
|
||||||
|
state.verification_attempt_started_at = undefined
|
||||||
state.verification_session_id = undefined
|
state.verification_session_id = undefined
|
||||||
state.initial_completion_promise ??= DEFAULT_COMPLETION_PROMISE
|
state.initial_completion_promise ??= DEFAULT_COMPLETION_PROMISE
|
||||||
|
|
||||||
@@ -156,6 +158,7 @@ export function createLoopStateController(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.verification_session_id = verificationSessionID
|
state.verification_session_id = verificationSessionID
|
||||||
|
state.verification_attempt_started_at = undefined
|
||||||
|
|
||||||
if (!writeState(directory, state, stateDir)) {
|
if (!writeState(directory, state, stateDir)) {
|
||||||
return null
|
return null
|
||||||
@@ -175,6 +178,7 @@ export function createLoopStateController(options: {
|
|||||||
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
|
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
|
||||||
state.verification_pending = undefined
|
state.verification_pending = undefined
|
||||||
state.verification_attempt_id = undefined
|
state.verification_attempt_id = undefined
|
||||||
|
state.verification_attempt_started_at = undefined
|
||||||
state.verification_session_id = undefined
|
state.verification_session_id = undefined
|
||||||
if (typeof messageCountAtStart === "number") {
|
if (typeof messageCountAtStart === "number") {
|
||||||
state.message_count_at_start = messageCountAtStart
|
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.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
|
||||||
state.verification_pending = undefined
|
state.verification_pending = undefined
|
||||||
state.verification_attempt_id = undefined
|
state.verification_attempt_id = undefined
|
||||||
|
state.verification_attempt_started_at = undefined
|
||||||
state.verification_session_id = undefined
|
state.verification_session_id = undefined
|
||||||
if (typeof messageCountAtStart === "number") {
|
if (typeof messageCountAtStart === "number") {
|
||||||
state.message_count_at_start = messageCountAtStart
|
state.message_count_at_start = messageCountAtStart
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { handleFailedVerification } from "./verification-failure-handler"
|
|||||||
import { withTimeout } from "./with-timeout"
|
import { withTimeout } from "./with-timeout"
|
||||||
import type { IterationCommitExpectation } from "./types"
|
import type { IterationCommitExpectation } from "./types"
|
||||||
|
|
||||||
|
export const STUCK_VERIFICATION_TIMEOUT_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
type OpenCodeSessionMessage = {
|
type OpenCodeSessionMessage = {
|
||||||
info?: { role?: string }
|
info?: { role?: string }
|
||||||
parts?: Array<{ type?: string; text?: string }>
|
parts?: Array<{ type?: string; text?: string }>
|
||||||
@@ -138,12 +140,25 @@ export async function handlePendingVerification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.verification_attempt_id && !state.verification_session_id) {
|
if (state.verification_attempt_id && !state.verification_session_id) {
|
||||||
log(`[${HOOK_NAME}] Skipped verification failure: oracle dispatch in flight`, {
|
const startedAt = state.verification_attempt_started_at
|
||||||
sessionID,
|
const attemptAgeMs = startedAt !== undefined ? Date.now() - startedAt : undefined
|
||||||
verificationAttemptId: state.verification_attempt_id,
|
const isStuck = attemptAgeMs !== undefined && attemptAgeMs > STUCK_VERIFICATION_TIMEOUT_MS
|
||||||
iteration: state.iteration,
|
|
||||||
})
|
if (isStuck) {
|
||||||
return
|
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, {
|
const restarted = await handleFailedVerification(ctx, {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ultrawork = data.ultrawork === true || data.ultrawork === "true" ? true : undefined
|
const ultrawork = data.ultrawork === true || data.ultrawork === "true" ? true : undefined
|
||||||
|
const verificationAttemptStartedAt = Number(data.verification_attempt_started_at)
|
||||||
const maxIterations =
|
const maxIterations =
|
||||||
data.max_iterations === undefined || data.max_iterations === ""
|
data.max_iterations === undefined || data.max_iterations === ""
|
||||||
? ultrawork
|
? ultrawork
|
||||||
@@ -65,6 +66,12 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
|
|||||||
verification_attempt_id: data.verification_attempt_id
|
verification_attempt_id: data.verification_attempt_id
|
||||||
? stripQuotes(data.verification_attempt_id)
|
? stripQuotes(data.verification_attempt_id)
|
||||||
: undefined,
|
: 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
|
verification_session_id: data.verification_session_id
|
||||||
? stripQuotes(data.verification_session_id)
|
? stripQuotes(data.verification_session_id)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -106,9 +113,19 @@ export function writeState(
|
|||||||
const initialCompletionPromiseLine = state.initial_completion_promise
|
const initialCompletionPromiseLine = state.initial_completion_promise
|
||||||
? `initial_completion_promise: "${state.initial_completion_promise}"\n`
|
? `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
|
const verificationAttemptLine = state.verification_attempt_id
|
||||||
? `verification_attempt_id: "${state.verification_attempt_id}"\n`
|
? `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
|
const verificationSessionLine = state.verification_session_id
|
||||||
? `verification_session_id: "${state.verification_session_id}"\n`
|
? `verification_session_id: "${state.verification_session_id}"\n`
|
||||||
: ""
|
: ""
|
||||||
@@ -124,7 +141,7 @@ export function writeState(
|
|||||||
active: ${state.active}
|
active: ${state.active}
|
||||||
iteration: ${state.iteration}
|
iteration: ${state.iteration}
|
||||||
${maxIterationsLine}completion_promise: "${state.completion_promise}"
|
${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}---
|
${sessionIdLine}${ultraworkLine}${verificationPendingLine}${strategyLine}${messageCountAtStartLine}---
|
||||||
${state.prompt}
|
${state.prompt}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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<typeof handlePendingVerification>[1]
|
||||||
|
type LoopStateController = PendingVerificationInput["loopState"]
|
||||||
|
|
||||||
|
function createState(verificationAttemptStartedAt?: number): RalphLoopState {
|
||||||
|
const state: RalphLoopState = {
|
||||||
|
active: true,
|
||||||
|
iteration: 2,
|
||||||
|
completion_promise: "<ulw-verification>",
|
||||||
|
initial_completion_promise: "<promise>DONE</promise>",
|
||||||
|
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<PluginInput>({
|
||||||
|
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<LoopStateController["clearVerificationState"]>(() => state)
|
||||||
|
const incrementIteration = mock<LoopStateController["incrementIteration"]>(() => state)
|
||||||
|
const loopState = {
|
||||||
|
restartAfterFailedVerification: mock<LoopStateController["restartAfterFailedVerification"]>(() => null),
|
||||||
|
clearVerificationState,
|
||||||
|
incrementIteration,
|
||||||
|
clear: mock<LoopStateController["clear"]>(() => true),
|
||||||
|
setVerificationSessionID: mock<LoopStateController["setVerificationSessionID"]>(() => 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ export interface RalphLoopState {
|
|||||||
completion_promise: string
|
completion_promise: string
|
||||||
initial_completion_promise?: string
|
initial_completion_promise?: string
|
||||||
verification_attempt_id?: string
|
verification_attempt_id?: string
|
||||||
|
verification_attempt_started_at?: number
|
||||||
verification_session_id?: string
|
verification_session_id?: string
|
||||||
started_at: string
|
started_at: string
|
||||||
prompt: string
|
prompt: string
|
||||||
|
|||||||
@@ -111,6 +111,83 @@ describe("runtime-fallback error classifier", () => {
|
|||||||
expect(retryable).toBe(true)
|
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", () => {
|
test("ignores malformed retryable flags on otherwise non-retryable errors", () => {
|
||||||
//#given
|
//#given
|
||||||
const error = {
|
const error = {
|
||||||
|
|||||||
@@ -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"
|
export { extractAutoRetrySignal } from "./auto-retry-signal"
|
||||||
|
|
||||||
@@ -119,6 +120,10 @@ export function extractRetryableSignal(error: unknown): boolean | undefined {
|
|||||||
return 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 {
|
function isLocalizedQuotaExhaustionMessage(message: string): boolean {
|
||||||
return (
|
return (
|
||||||
(/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) ||
|
(/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) ||
|
||||||
@@ -221,8 +226,16 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extractRetryableSignal(error) === true) {
|
const retryableSignal = extractRetryableSignal(error)
|
||||||
return true
|
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))
|
return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user