diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index ed81ec70bbb..ab5a3e8b826 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -14,7 +14,7 @@ describe("getNotifyScriptContent", () => { "SESSION_ID=" + "\u0024{RESOURCE_ID:-$HOOK_SESSION_ID}", ); expect(script).toContain( - 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"', + 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"workspaceId\\":\\"$(json_escape "$SUPERSET_WORKSPACE_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"', ); expect(script).toContain('--data-urlencode "resourceId=$RESOURCE_ID"'); expect(script).toContain( @@ -25,15 +25,24 @@ describe("getNotifyScriptContent", () => { ); }); - it("gives the v2 host-service hook enough time to avoid false fallback", () => { + it("keeps the legacy hook after the v2 host-service hook", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), "utf-8", ); - expect(script).toContain( - 'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \\\n --connect-timeout 2 --max-time 5', + const hostServiceCurlIndex = script.indexOf( + 'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL"', ); + const legacyCurlIndex = script.indexOf( + 'curl -sG "http://127.0.0.1:' + + "$" + + '{SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete"', + ); + + expect(hostServiceCurlIndex).toBeGreaterThanOrEqual(0); + expect(legacyCurlIndex).toBeGreaterThanOrEqual(0); + expect(hostServiceCurlIndex).toBeLessThan(legacyCurlIndex); }); it("keeps the legacy v1 fallback path when no host-service hook URL exists", () => { diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 7259ad9c509..aaa43f9ca48 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -85,11 +85,10 @@ json_escape() { # v2: host-service tRPC endpoint. The renderer subscribes over the event # bus and plays the ringtone. Preferred when the URL is provided by # host-service's terminal env. Endpoint is unauthenticated — it only -# broadcasts chimes, no auth header needed. Always captures the status -# so we can fall back to v1 when host-service is unreachable or the -# mutation returns non-2xx (restarts, crashes, transient errors). +# broadcasts chimes, no auth header needed. We still continue to the legacy +# desktop hook below so existing v1 listeners also receive lifecycle events. if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then - PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}" + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}" STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ --connect-timeout 2 --max-time 5 \ @@ -100,14 +99,17 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2 fi +fi - case "$STATUS_CODE" in - 2*) exit 0 ;; - esac +# Pure v2 terminals only have terminal/workspace ids, so host-service is the +# notification path. Continue to the legacy desktop hook only when the shell +# also has legacy pane/tab identity; otherwise v2 users would get duplicates. +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -z "$SUPERSET_PANE_ID" ] && [ -z "$SUPERSET_TAB_ID" ]; then + exit 0 fi -# v1 fallback: electron localhost server. Used by v1 terminals and when -# host-service is unreachable from the agent's shell. +# v1/legacy: electron localhost server. Used by v1 terminals and by hybrid +# environments that still expose legacy pane/tab identity. # Timeouts prevent blocking agent completion if notification server is unresponsive if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ diff --git a/packages/host-service/src/trpc/router/notifications/notifications.test.ts b/packages/host-service/src/trpc/router/notifications/notifications.test.ts index 381492d416f..b00ead0f1b0 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.test.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.test.ts @@ -89,6 +89,26 @@ describe("notificationsRouter.hook", () => { expect(unknownTerminal.broadcastAgentLifecycle).not.toHaveBeenCalled(); }); + it("falls back to payload workspaceId when the terminal row is not visible yet", async () => { + const { ctx, broadcastAgentLifecycle, findFirst } = createContext(null); + const caller = notificationsRouter.createCaller(ctx); + + const result = await caller.hook({ + terminalId: "terminal-1", + workspaceId: "workspace-1", + eventType: "Stop", + }); + + expect(result).toEqual({ success: true, ignored: false }); + expect(findFirst).toHaveBeenCalledTimes(1); + expect(broadcastAgentLifecycle).toHaveBeenCalledTimes(1); + expect(broadcastAgentLifecycle.mock.calls[0]?.[0]).toMatchObject({ + workspaceId: "workspace-1", + eventType: "Stop", + terminalId: "terminal-1", + }); + }); + it("ignores unknown event types before looking up the terminal", async () => { const { ctx, broadcastAgentLifecycle, findFirst } = createContext("workspace-1"); diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index e231ad7e24c..a9eb68bf15d 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -5,11 +5,14 @@ import { mapEventType } from "../../../events"; import { publicProcedure, router } from "../../index"; /** - * v2 terminal hook payload. The shell hook sends only stable runtime identity; - * host-service derives workspace identity from its terminal session table. + * v2 terminal hook payload. The shell hook sends stable runtime identity. + * Host-service prefers the terminal session table for workspace identity, but + * falls back to the terminal env's workspaceId so lifecycle events are not lost + * if a hook arrives while a new workspace/terminal is still settling. */ const hookInput = z.object({ terminalId: z.string().optional(), + workspaceId: z.string().optional(), eventType: z.string().optional(), }); @@ -44,12 +47,13 @@ export const notificationsRouter = router({ columns: { originWorkspaceId: true }, }) .sync(); - if (!terminalSession?.originWorkspaceId) { + const workspaceId = terminalSession?.originWorkspaceId ?? input.workspaceId; + if (!workspaceId) { return { success: true, ignored: true as const }; } ctx.eventBus.broadcastAgentLifecycle({ - workspaceId: terminalSession.originWorkspaceId, + workspaceId, eventType, terminalId: input.terminalId, occurredAt: Date.now(),