fix: resolve 25 pre-publish blockers
- postinstall.mjs: fix alias package detection - migrate-legacy-plugin-entry: dedupe + regression tests - task_system: default consistency across runtime paths - task() contract: consistent tool behavior - runtime model selection, tool cap, stale-task cancellation - recovery sanitization, context-limit gating - Ralph semantic DONE hardening, Atlas fallback persistence - native-skill description/content, skill path traversal guard - publish workflow: platform awaited via reusable workflow job - release: version edits reapplied before commit/tag - JSONC plugin migration: top-level plugin key safety - cold-cache: user fallback models skip disconnected providers - docs/version/release framing updates Verified: bun test (4599 pass), tsc --noEmit clean, bun run build clean
This commit is contained in:
@@ -3312,6 +3312,9 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
get: async () => {
|
||||
throw new Error("missing")
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
|
||||
@@ -3348,6 +3351,9 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
get: async () => {
|
||||
throw new Error("missing")
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
|
||||
@@ -3437,6 +3443,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
consecutiveMissedPolls: 2,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
@@ -3471,6 +3478,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||||
progress: undefined,
|
||||
consecutiveMissedPolls: 2,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
@@ -8,6 +8,7 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
abort: mock(() => Promise.resolve()),
|
||||
get: mock(() => Promise.resolve({ data: { id: "ses-1" } })),
|
||||
},
|
||||
}
|
||||
const mockConcurrencyManager = {
|
||||
@@ -35,6 +36,11 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
beforeEach(() => {
|
||||
fixedTime = Date.now()
|
||||
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
|
||||
mockClient.session.abort.mockClear()
|
||||
mockClient.session.get.mockReset()
|
||||
mockClient.session.get.mockResolvedValue({ data: { id: "ses-1" } })
|
||||
mockConcurrencyManager.release.mockClear()
|
||||
mockNotify.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -288,6 +294,59 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
expect(task.status).toBe("running")
|
||||
})
|
||||
|
||||
it("should NOT cancel healthy task on first missing status poll", async () => {
|
||||
//#given — one missing poll should not be enough to declare the session gone
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 120_000),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.consecutiveMissedPolls).toBe(1)
|
||||
expect(mockClient.session.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should NOT cancel task when session.get confirms the session still exists", async () => {
|
||||
//#given — repeated missing polls but direct lookup still succeeds
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 300_000),
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 120_000),
|
||||
},
|
||||
consecutiveMissedPolls: 2,
|
||||
})
|
||||
|
||||
//#when
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
client: mockClient as never,
|
||||
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
||||
concurrencyManager: mockConcurrencyManager as never,
|
||||
notifyParentSession: mockNotify,
|
||||
sessionStatuses: {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.consecutiveMissedPolls).toBe(0)
|
||||
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: "ses-1" } })
|
||||
})
|
||||
|
||||
it("should use session-gone timeout when session is missing from status map (with progress)", async () => {
|
||||
//#given — lastUpdate 2min ago, session completely gone from status
|
||||
const task = createRunningTask({
|
||||
@@ -296,8 +355,11 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 120_000),
|
||||
},
|
||||
consecutiveMissedPolls: 2,
|
||||
})
|
||||
|
||||
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
||||
|
||||
//#when — empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
@@ -318,8 +380,11 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
const task = createRunningTask({
|
||||
startedAt: new Date(Date.now() - 120_000),
|
||||
progress: undefined,
|
||||
consecutiveMissedPolls: 2,
|
||||
})
|
||||
|
||||
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
||||
|
||||
//#when — session gone, sessionGoneTimeoutMs = 60s
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
@@ -343,8 +408,11 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 120_000),
|
||||
},
|
||||
consecutiveMissedPolls: 2,
|
||||
})
|
||||
|
||||
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
||||
|
||||
//#when — session is idle (present in map), staleTimeoutMs = 180s
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
@@ -367,8 +435,11 @@ describe("checkAndInterruptStaleTasks", () => {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(Date.now() - 120_000),
|
||||
},
|
||||
consecutiveMissedPolls: 2,
|
||||
})
|
||||
|
||||
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
||||
|
||||
//#when — no config (default sessionGoneTimeoutMs = 60_000)
|
||||
await checkAndInterruptStaleTasks({
|
||||
tasks: [task],
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||
|
||||
import { isActiveSessionStatus } from "./session-status-classifier"
|
||||
|
||||
const MIN_SESSION_GONE_POLLS = 3
|
||||
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
|
||||
"completed",
|
||||
"error",
|
||||
@@ -97,6 +99,15 @@ export function pruneStaleTasksAndNotifications(args: {
|
||||
|
||||
export type SessionStatusMap = Record<string, { type: string }>
|
||||
|
||||
async function verifySessionExists(client: OpencodeClient, sessionID: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await client.session.get({ path: { id: sessionID } })
|
||||
return !!result.data
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAndInterruptStaleTasks(args: {
|
||||
tasks: Iterable<BackgroundTask>
|
||||
client: OpencodeClient
|
||||
@@ -130,14 +141,28 @@ export async function checkAndInterruptStaleTasks(args: {
|
||||
|
||||
const sessionStatus = sessionStatuses?.[sessionID]?.type
|
||||
const sessionIsRunning = sessionStatus !== undefined && isActiveSessionStatus(sessionStatus)
|
||||
const sessionGone = sessionStatuses !== undefined && sessionStatus === undefined
|
||||
const sessionMissing = sessionStatuses !== undefined && sessionStatus === undefined
|
||||
const runtime = now - startedAt.getTime()
|
||||
|
||||
if (sessionMissing) {
|
||||
task.consecutiveMissedPolls = (task.consecutiveMissedPolls ?? 0) + 1
|
||||
} else if (sessionStatuses !== undefined) {
|
||||
task.consecutiveMissedPolls = 0
|
||||
}
|
||||
|
||||
const sessionGone = sessionMissing && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
|
||||
|
||||
if (!task.progress?.lastUpdate) {
|
||||
if (sessionIsRunning) continue
|
||||
if (sessionMissing && !sessionGone) continue
|
||||
const effectiveTimeout = sessionGone ? sessionGoneTimeoutMs : messageStalenessMs
|
||||
if (runtime <= effectiveTimeout) continue
|
||||
|
||||
if (sessionGone && await verifySessionExists(client, sessionID)) {
|
||||
task.consecutiveMissedPolls = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const staleMinutes = Math.round(runtime / 60000)
|
||||
const reason = sessionGone ? "session gone from status registry" : "no activity"
|
||||
task.status = "cancelled"
|
||||
@@ -171,11 +196,16 @@ export async function checkAndInterruptStaleTasks(args: {
|
||||
if (timeSinceLastUpdate <= effectiveStaleTimeout) continue
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
|
||||
const reason = sessionGone ? "session gone from status registry" : "no activity"
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (${reason} for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/oh-my-opencode.json.`
|
||||
task.completedAt = new Date()
|
||||
if (sessionGone && await verifySessionExists(client, sessionID)) {
|
||||
task.consecutiveMissedPolls = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
|
||||
const reason = sessionGone ? "session gone from status registry" : "no activity"
|
||||
task.status = "cancelled"
|
||||
task.error = `Stale timeout (${reason} for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/oh-my-opencode.json.`
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface BackgroundTask {
|
||||
lastMsgCount?: number
|
||||
/** Number of consecutive polls with stable message count */
|
||||
stablePolls?: number
|
||||
/** Number of consecutive polls where session was missing from status map */
|
||||
consecutiveMissedPolls?: number
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
|
||||
@@ -3,9 +3,13 @@ import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { LoadedSkill } from "./types"
|
||||
|
||||
export function extractSkillTemplate(skill: LoadedSkill): string {
|
||||
if (skill.path) {
|
||||
const content = readFileSync(skill.path, "utf-8")
|
||||
const { body } = parseFrontmatter(content)
|
||||
if (skill.scope === "config" && skill.definition.template) {
|
||||
return skill.definition.template
|
||||
}
|
||||
|
||||
if (skill.path) {
|
||||
const content = readFileSync(skill.path, "utf-8")
|
||||
const { body } = parseFrontmatter(content)
|
||||
return body.trim()
|
||||
}
|
||||
return skill.definition.template || ""
|
||||
|
||||
Reference in New Issue
Block a user