Skip to content

feat(hub): add ServerChan task notifications#515

Merged
tiann merged 7 commits intotiann:mainfrom
xiaobaifly7:feat/serverchan-task-notifications
Apr 25, 2026
Merged

feat(hub): add ServerChan task notifications#515
tiann merged 7 commits intotiann:mainfrom
xiaobaifly7:feat/serverchan-task-notifications

Conversation

@xiaobaifly7
Copy link
Copy Markdown
Contributor

Summary

  • add ServerChan notification channel configuration and wiring in hub startup
  • route task_notification events through NotificationHub to push, Telegram, and ServerChan channels
  • cover task notification parsing and hub dispatch with tests

Testing

  • bun run typecheck
  • bun test src/notifications/eventParsing.test.ts src/notifications/notificationHub.test.ts
  • bun run build

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Direct data.content task-notification payloads are still dropped, so some completed background tasks will never notify. Evidence hub/src/notifications/eventParsing.ts:76, related existing handling hub/src/sync/backgroundTasks.ts:85.
  • [Major] Visible web clients no longer get task-completion toasts because sendTaskNotification skips the existing SSE fallback used by the other push notifications. Evidence hub/src/push/pushNotificationChannel.ts:95.

Summary
Review mode: initial
Found 2 issues on the current head.

Testing
Not run (security/automation)

HAPI Bot

return null
}

const wrappedMessage = isObject(data.message) ? data.message : null
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] This only reads data.message.content, but the hub already treats { type: 'user', content: '<task-notification>...' } as a valid completion shape in hub/src/sync/backgroundTasks.ts. Those events will update background-task state without sending any notification. Please accept both payload shapes here and cover the direct data.content variant in the tests.

Suggested fix:

const content = typeof data.content === 'string'
    ? data.content
    : isObject(data.message) && typeof data.message.content === 'string'
        ? data.message.content
        : null
if (content == null) {
    return null
}

await this.pushService.sendToNamespace(session.namespace, payload)
}

async sendTaskNotification(session: Session, notification: TaskNotification): Promise<void> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] sendTaskNotification goes straight to Web Push and skips the visible-tab SSE toast path that sendPermissionRequest and sendReady already use. When the web app is open, task completions will either become intrusive push notifications or disappear entirely for users who rely on the in-app toast path. Reuse the same visibilityTracker / sseManager.sendToast(...) fallback before calling pushService.

Suggested fix:

const url = payload.data?.url ?? this.buildSessionPath(session.id)
if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
    const delivered = await this.sseManager.sendToast(session.namespace, {
        type: 'toast',
        data: {
            title: payload.title,
            body: payload.body,
            sessionId: session.id,
            url
        }
    })
    if (delivered > 0) {
        return
    }
}

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Crash exits are now reported as completed in four wrappers. markCrash(...) sets sessionEndReason = 'error', but the new finally blocks overwrite that to completed whenever there is no local launch failure. That makes failed Codex/Cursor/Gemini/OpenCode sessions emit session-end.reason="completed", which triggers false completion notifications and hides real failures from downstream handling. Evidence cli/src/codex/runCodex.ts:220, cli/src/codex/runCodex.ts:228, cli/src/cursor/runCursor.ts:130, cli/src/gemini/runGemini.ts:182, cli/src/opencode/runOpencode.ts:136, related state setter cli/src/agent/runnerLifecycle.ts:100.
    Suggested fix:
    let crashed = false
    try {
        await loop(...)
    } catch (error) {
        crashed = true
        lifecycle.markCrash(error)
        logger.debug('[codex] Loop error:', error)
    } finally {
        const localFailure = sessionWrapperRef.current?.localLaunchFailure
        if (localFailure?.exitReason === 'exit') {
            lifecycle.setExitCode(1)
            lifecycle.setArchiveReason(`Local launch failed: ${formatFailureReason(localFailure.message)}`)
            lifecycle.setSessionEndReason('error')
        } else if (!crashed) {
            lifecycle.setSessionEndReason('completed')
        }
        await lifecycle.cleanupAndExit()
    }
  • [Major] Direct data.content task-notification payloads are still dropped. extractBackgroundTaskDelta already treats { type: 'user', content: '<task-notification>...' } as a completion, but extractTaskNotification only reads data.message.content, so some completed tasks will update counters without notifying any channel. Evidence hub/src/notifications/eventParsing.ts:76, related existing handling hub/src/sync/backgroundTasks.ts:77; missing coverage in hub/src/notifications/eventParsing.test.ts:111.
    Suggested fix:
    const content = typeof data.content === 'string'
        ? data.content
        : isObject(data.message) && typeof data.message.content === 'string'
            ? data.message.content
            : null
    if (content == null) {
        return null
    }
  • [Major] Visible web clients still bypass the in-app toast path for task notifications. sendTaskNotification now always goes straight to Web Push, unlike sendReady and sendPermissionRequest. When the PWA tab is open, task results either become browser push notifications or disappear for users who rely on the existing SSE toast channel. Evidence hub/src/push/pushNotificationChannel.ts:95.
    Suggested fix:
    const url = payload.data?.url ?? this.buildSessionPath(session.id)
    if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
        const delivered = await this.sseManager.sendToast(session.namespace, {
            type: 'toast',
            data: {
                title: payload.title,
                body: payload.body,
                sessionId: session.id,
                url
            }
        })
        if (delivered > 0) {
            return
        }
    }
    
    await this.pushService.sendToNamespace(session.namespace, payload)

Summary
Review mode: follow-up after new commits
Found 3 issues on the current head.

Testing
Not run (automation)

HAPI Bot

Comment thread cli/src/codex/runCodex.ts
lifecycle.setArchiveReason(`Local launch failed: ${formatFailureReason(localFailure.message)}`);
lifecycle.setSessionEndReason('error');
} else {
lifecycle.setSessionEndReason('completed');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] markCrash(...) already sets sessionEndReason = 'error' in RunnerLifecycle, but this new else branch overwrites it to 'completed' for any loop exception that is not a local launch failure. The same pattern is repeated in cli/src/cursor/runCursor.ts, cli/src/gemini/runGemini.ts, and cli/src/opencode/runOpencode.ts, so crashed sessions will emit session-end.reason="completed" and trigger false completion notifications.

Suggested fix:

let crashed = false
try {
    await loop(...)
} catch (error) {
    crashed = true
    lifecycle.markCrash(error)
    logger.debug('[codex] Loop error:', error)
} finally {
    const localFailure = sessionWrapperRef.current?.localLaunchFailure
    if (localFailure?.exitReason === 'exit') {
        lifecycle.setExitCode(1)
        lifecycle.setArchiveReason(`Local launch failed: ${formatFailureReason(localFailure.message)}`)
        lifecycle.setSessionEndReason('error')
    } else if (!crashed) {
        lifecycle.setSessionEndReason('completed')
    }
    await lifecycle.cleanupAndExit()
}

return null
}

const wrappedMessage = isObject(data.message) ? data.message : null
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] This still only reads data.message.content, but the hub already accepts { type: 'user', content: '<task-notification>...' } as a completion shape in hub/src/sync/backgroundTasks.ts. Those events will update background-task counts without sending any notification. Please accept both payload shapes here and cover the direct data.content variant in the tests.

Suggested fix:

const content = typeof data.content === 'string'
    ? data.content
    : isObject(data.message) && typeof data.message.content === 'string'
        ? data.message.content
        : null
if (content == null) {
    return null
}

await this.pushService.sendToNamespace(session.namespace, payload)
}

async sendTaskNotification(session: Session, notification: TaskNotification): Promise<void> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] sendTaskNotification still goes straight to Web Push and skips the visible-tab SSE toast path that sendPermissionRequest and sendReady already use. When the web app is open, task completions will either become intrusive push notifications or disappear for users who rely on the in-app toast path. Reuse the same visibilityTracker / sseManager.sendToast(...) fallback before calling pushService.

Suggested fix:

const url = payload.data?.url ?? this.buildSessionPath(session.id)
if (this.visibilityTracker.hasVisibleConnection(session.namespace)) {
    const delivered = await this.sseManager.sendToast(session.namespace, {
        type: 'toast',
        data: {
            title: payload.title,
            body: payload.body,
            sessionId: session.id,
            url
        }
    })
    if (delivered > 0) {
        return
    }
}

await this.pushService.sendToNamespace(session.namespace, payload)

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Summary
Review mode: follow-up after new commits
Found 2 issues on the current head.

Testing
Not run (bun is not installed in this runner, so bun test / bun typecheck were unavailable)

HAPI Bot

clearInterval(keepAliveInterval);
await permissionAdapter.cancelAll('Session ended');
session.sendSessionDeath();
session.sendSessionDeath(shouldExit ? 'terminated' : 'completed');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] The ACP runner still reports unhandled failures as completed. This new reason is derived only from shouldExit, so any exception that escapes the loop before shutdown still reaches this finally block with shouldExit === false and emits session-end.reason="completed". That now drives false session-ended completion notifications downstream for Gemini/ACP sessions.

Suggested fix:

let endReason: SessionEndReason = 'terminated'
try {
    while (!shouldExit) {
        // existing loop body
    }
    if (!shouldExit) {
        endReason = 'completed'
    }
} catch (error) {
    endReason = 'error'
    throw error
} finally {
    clearInterval(keepAliveInterval)
    await permissionAdapter.cancelAll('Session ended')
    session.sendSessionDeath(shouldExit ? 'terminated' : endReason)
    await session.flush()
    session.close()
    await backend.disconnect()
    happyServer.stop()
}

Comment thread hub/src/push/pushNotificationChannel.ts Outdated
const payload: PushPayload = {
title: isFailure ? 'Task failed' : 'Task completed',
body: `${agentName} · ${name} · ${notification.summary}`,
tag: `task-${session.id}`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] All push task notifications for the same session now share tag: \task-${session.id}`. In the service worker, that tag is passed straight to showNotification(...)`, so later task updates replace earlier ones instead of stacking. A failed task can therefore be overwritten by the next completion in the same session, which loses the earlier alert entirely for background/mobile users.

Suggested fix:

const payload: PushPayload = {
    title: isFailure ? 'Task failed' : 'Task completed',
    body: `${agentName} · ${name} · ${notification.summary}`,
    data: {
        type: 'task-notification',
        sessionId: session.id,
        url: this.buildSessionPath(session.id)
    }
}
If you still want deduplication, use a per-task unique tag instead of a per-session tag.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings
No issues found.

Summary
Review mode: follow-up after new commits
No issues found on the current head. Prior bot findings appear addressed. Residual risk: static review only; I did not execute the PR test commands in this automation pass.

Testing
Not run (automation; static review only)

HAPI Bot

@xiaobaifly7
Copy link
Copy Markdown
Contributor Author

@tiann 我已经搞定了,麻烦合并一下。

@tiann
Copy link
Copy Markdown
Owner

tiann commented Apr 25, 2026

Thank you!

@tiann tiann merged commit 4ec9537 into tiann:main Apr 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants