Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 87 additions & 19 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ async function sendIgnoredMessage(
noReply?: boolean
beforeActiveAssistant?: boolean
canSend?: () => boolean
onMessageId?: (messageId: string) => void
} = {},
): Promise<boolean> {
const session = ctx.client.session as PluginSessionClient | undefined
Expand Down Expand Up @@ -653,6 +654,7 @@ async function sendIgnoredMessage(
// A new user prompt can start while that request is in flight, so re-check the
// caller's delivery lease immediately before inserting the ignored message.
if (options.canSend && !options.canSend()) return false
if (request.body.messageID) options.onMessageId?.(request.body.messageID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a second desktop notice is inserted before another assistant message exists, this callback does not mark it as plugin-generated. The placement helper omits messageID once the previous notice is the latest user message, so OpenCode assigns an untracked latest-user ID; that can revoke the lease and re-enter the provider for a duplicate billed turn. Generate a unique ordered ID for every notice, or correlate the host-assigned ID before applying the genuine-user filter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 657:

<comment>When a second desktop notice is inserted before another assistant message exists, this callback does not mark it as plugin-generated. The placement helper omits `messageID` once the previous notice is the latest user message, so OpenCode assigns an untracked latest-user ID; that can revoke the lease and re-enter the provider for a duplicate billed turn. Generate a unique ordered ID for every notice, or correlate the host-assigned ID before applying the genuine-user filter.</comment>

<file context>
@@ -653,6 +654,7 @@ async function sendIgnoredMessage(
   // A new user prompt can start while that request is in flight, so re-check the
   // caller's delivery lease immediately before inserting the ignored message.
   if (options.canSend && !options.canSend()) return false
+  if (request.body.messageID) options.onMessageId?.(request.body.messageID)
 
   if (typeof session?.promptAsync === 'function') {
</file context>


if (typeof session?.promptAsync === 'function') {
await session.promptAsync(request)
Expand Down Expand Up @@ -1087,6 +1089,8 @@ const anthropicAuthPlugin = async (
const desktopNoticeSafeSessions = new Set<string>()
const desktopNoticeLatestUserMessages = new Map<string, string>()
const desktopNoticeIdleUserMessages = new Map<string, string>()
const desktopNoticeMessageIds = new Map<string, Set<string>>()
const desktopNoticeUserRevisions = new Map<string, number>()
const desktopNoticeProbes = new Map<string, number>()
const stickySessionRouter = new StickySessionRouter({
path:
Expand Down Expand Up @@ -4044,6 +4048,11 @@ const anthropicAuthPlugin = async (

function queueDesktopNotice(sessionId: string, text: string) {
if (isTuiConnected(sessionId)) return
logger.debug('fable-fallback', 'Desktop notification queued', {
session: sessionId,
safe: desktopNoticeSafeSessions.has(sessionId),
text,
})
// OpenCode's prompt endpoints run revert cleanup before honoring noReply.
// OpenCode awaits event handlers before it evaluates the loop exit condition.
// Escape the post-idle session update, then probe outside that critical section.
Expand All @@ -4063,6 +4072,36 @@ const anthropicAuthPlugin = async (
}
}

function rememberDesktopNoticeMessageId(
sessionId: string,
messageId: string,
) {
const messageIds =
desktopNoticeMessageIds.get(sessionId) ?? new Set<string>()
messageIds.add(messageId)
while (messageIds.size > 8) {
const oldest = messageIds.values().next().value
if (oldest) messageIds.delete(oldest)
else break
}
desktopNoticeMessageIds.delete(sessionId)
desktopNoticeMessageIds.set(sessionId, messageIds)
while (desktopNoticeMessageIds.size > 128) {
const oldest = desktopNoticeMessageIds.keys().next().value
if (oldest) desktopNoticeMessageIds.delete(oldest)
else break
}
}

function grantDesktopNoticeLease(sessionId: string) {
desktopNoticeSafeSessions.add(sessionId)
while (desktopNoticeSafeSessions.size > 128) {
const oldest = desktopNoticeSafeSessions.values().next().value
if (oldest) desktopNoticeSafeSessions.delete(oldest)
else break
}
}

function scheduleDesktopNoticeProbe(sessionId: string, attempt = 0) {
if (
!pendingDesktopNotices.has(sessionId) ||
Expand Down Expand Up @@ -4090,6 +4129,12 @@ const anthropicAuthPlugin = async (
}

async function flushDesktopNoticesIfIdle(sessionId: string, attempt: number) {
logger.debug('fable-fallback', 'Desktop notification flush considered', {
session: sessionId,
attempt,
safe: desktopNoticeSafeSessions.has(sessionId),
pending: pendingDesktopNotices.has(sessionId),
})
if (
!desktopNoticeSafeSessions.has(sessionId) ||
!pendingDesktopNotices.has(sessionId)
Expand Down Expand Up @@ -4150,13 +4195,28 @@ const anthropicAuthPlugin = async (
return
}
try {
const userRevision = desktopNoticeUserRevisions.get(sessionId) ?? 0
const sent = await sendIgnoredMessage(ctx, sessionId, text, {
noReply: true,
beforeActiveAssistant: true,
canSend: () => desktopNoticeSafeSessions.has(sessionId),
onMessageId: (messageId) =>
rememberDesktopNoticeMessageId(sessionId, messageId),
})
if (!sent) return
queue.shift()
if (
!desktopNoticeSafeSessions.has(sessionId) &&
pendingDesktopNotices.get(sessionId)?.[0] &&
(desktopNoticeUserRevisions.get(sessionId) ?? 0) === userRevision
) {
// OpenCode marks its own noReply insertion busy without publishing a
// new idle event. Re-enter only through the same live-status probe;
// a genuine user message changes the revision and blocks this path.
grantDesktopNoticeLease(sessionId)
scheduleDesktopNoticeProbe(sessionId)
return
}
} catch (error) {
logger.warn('fable-fallback', 'Desktop notification failed', {
session: sessionId,
Expand Down Expand Up @@ -5237,21 +5297,32 @@ const anthropicAuthPlugin = async (
if (!sessionId) return

if (value.type === 'message.updated' && info?.role === 'user') {
if (typeof info.id === 'string') {
desktopNoticeLatestUserMessages.set(sessionId, info.id)
if (
desktopNoticeSafeSessions.has(sessionId) &&
desktopNoticeIdleUserMessages.get(sessionId) !== info.id
) {
// A new user message can precede OpenCode's busy status event. Revoke
// the idle-delivery lease immediately so an ignored notice cannot
// become the active request parent and duplicate a provider turn.
// Repeated updates for the user message that produced the current
// idle event are harmless and must not suppress delivery forever.
// promptAsync publishes ignored notices as user-message updates. They do
// not start a provider turn, so only genuine user messages revoke the lease.
const isDesktopNotice =
typeof info.id === 'string' &&
desktopNoticeMessageIds.get(sessionId)?.has(info.id)
if (!isDesktopNotice) {
desktopNoticeUserRevisions.set(
sessionId,
(desktopNoticeUserRevisions.get(sessionId) ?? 0) + 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A repeated update for the user message that produced the current idle event now changes desktopNoticeUserRevisions even though it is not a new prompt. Increment the revision only when the user ID differs from desktopNoticeIdleUserMessages, otherwise a queued notice can remain stranded without a new idle event.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 5308:

<comment>A repeated update for the user message that produced the current idle event now changes `desktopNoticeUserRevisions` even though it is not a new prompt. Increment the revision only when the user ID differs from `desktopNoticeIdleUserMessages`, otherwise a queued notice can remain stranded without a new idle event.</comment>

<file context>
@@ -5237,21 +5297,32 @@ const anthropicAuthPlugin = async (
+        if (!isDesktopNotice) {
+          desktopNoticeUserRevisions.set(
+            sessionId,
+            (desktopNoticeUserRevisions.get(sessionId) ?? 0) + 1,
+          )
+          if (typeof info.id === 'string') {
</file context>

)
if (typeof info.id === 'string') {
desktopNoticeLatestUserMessages.set(sessionId, info.id)
if (
desktopNoticeSafeSessions.has(sessionId) &&
desktopNoticeIdleUserMessages.get(sessionId) !== info.id
) {
// A new user message can precede OpenCode's busy status event. Revoke
// the idle-delivery lease immediately so an ignored notice cannot
// become the active request parent and duplicate a provider turn.
// Repeated updates for the user message that produced the current
// idle event are harmless and must not suppress delivery forever.
desktopNoticeSafeSessions.delete(sessionId)
}
} else {
desktopNoticeSafeSessions.delete(sessionId)
}
} else {
desktopNoticeSafeSessions.delete(sessionId)
}
}

Expand All @@ -5274,12 +5345,7 @@ const anthropicAuthPlugin = async (
// live status map is still idle. OpenCode 1.18 no longer guarantees a
// session.updated event after session.idle, so that event cannot be used
// as the release signal.
desktopNoticeSafeSessions.add(sessionId)
while (desktopNoticeSafeSessions.size > 128) {
const oldest = desktopNoticeSafeSessions.values().next().value
if (oldest) desktopNoticeSafeSessions.delete(oldest)
else break
}
grantDesktopNoticeLease(sessionId)
scheduleDesktopNoticeProbe(sessionId)
}

Expand All @@ -5297,6 +5363,8 @@ const anthropicAuthPlugin = async (
desktopNoticeSafeSessions.delete(sessionId)
desktopNoticeLatestUserMessages.delete(sessionId)
desktopNoticeIdleUserMessages.delete(sessionId)
desktopNoticeMessageIds.delete(sessionId)
desktopNoticeUserRevisions.delete(sessionId)
for (const recoveryKey of pendingRecoveryDesktopNotices.keys()) {
if (recoveryKey.startsWith(`${sessionId}\0`)) {
pendingRecoveryDesktopNotices.delete(recoveryKey)
Expand Down
35 changes: 19 additions & 16 deletions packages/opencode/src/tests/custody-handle-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1655,27 +1655,30 @@ describe('withCustodyManifestLock', () => {
test.serial('reports a held lock as lock_busy', async () => {
await withTempDirectory(async (directory) => {
const path = join(directory, 'handles.json')
const firstEntered = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<void>()
const lockPath = `${path}.lock`
const times = [0, 29, 30]
let timeIndex = 0
await fs.mkdir(lockPath, { mode: 0o700 })
await fs.writeFile(
join(lockPath, 'owner'),
`${JSON.stringify({
tenant: 'anthropic-auth',
pid: process.pid,
claimed_at_ms: 0,
nonce: 'held-test',
})}\n`,
)
__setCustodyManifestLockTestOptions({
ttlMs: 30,
retryMinMs: 1,
retryMaxMs: 1,
renewalIntervalMs: 5,
})
const first = withCustodyManifestLock(path, async () => {
firstEntered.resolve()
await releaseFirst.promise
now: () => times[Math.min(timeIndex++, times.length - 1)]!,
})
try {
await firstEntered.promise
await expect(
withCustodyManifestLock(path, async () => 'acquired'),
).rejects.toMatchObject({ code: 'lock_busy' })
} finally {
releaseFirst.resolve()
await first
}

await expect(
withCustodyManifestLock(path, async () => 'acquired'),
).rejects.toMatchObject({ code: 'lock_busy' })
await expect(fs.lstat(lockPath)).resolves.toBeDefined()
})
})

Expand Down
Loading