diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts index 615d751f04..0401f08ec6 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts @@ -241,8 +241,11 @@ describe("conversation turn continuation", () => { ], ]; const onRunnerReady = vi.fn(); + const onTurnAccepted = vi.fn(); - const result = await runConversationTurn(params({ onRunnerReady })); + const result = await runConversationTurn( + params({ onRunnerReady, onTurnAccepted }) + ); expect(result).toMatchObject({ runnerSessionId: "fresh-runner", @@ -259,6 +262,10 @@ describe("conversation turn continuation", () => { "intent-1", "intent-1" ); + expect(onTurnAccepted).toHaveBeenCalledWith("fresh-runner", "intent-1"); + expect(onRunnerReady.mock.invocationCallOrder[0]).toBeLessThan( + onTurnAccepted.mock.invocationCallOrder[0] + ); expect(loadContinuation("scope", "root")).toMatchObject({ continuationSessionId: "fresh-runner", established: true, @@ -340,6 +347,49 @@ describe("conversation turn continuation", () => { expect(state.cleaned).toContain("runner-dead"); }); + it("keeps a durably prepared resume on transport ambiguity", async () => { + saveContinuation("scope", "root", { + continuationSessionId: "runner-prepared", + readThroughPlaneSeq: 10, + established: true, + agentDefinitionId: "agent-a", + }); + state.rejectNextSend = true; + + await expect( + runConversationTurn(params({ preserveRunnerOnTransportFailure: true })) + ).rejects.toThrow("session cannot accept turns"); + + expect(SessionService.create).not.toHaveBeenCalled(); + expect(state.cleaned).not.toContain("runner-prepared"); + expect(loadContinuation("scope", "root")).toMatchObject({ + continuationSessionId: "runner-prepared", + established: true, + }); + }); + + it("keeps a fresh prepared runner recoverable when its first send is ambiguous", async () => { + state.rejectNextSend = true; + const onTurnAccepted = vi.fn(); + + await expect( + runConversationTurn( + params({ + preserveRunnerOnTransportFailure: true, + onTurnAccepted, + }) + ) + ).rejects.toThrow("session cannot accept turns"); + + expect(onTurnAccepted).not.toHaveBeenCalled(); + expect(state.cleaned).not.toContain("fresh-runner"); + expect(loadContinuation("scope", "root")).toMatchObject({ + continuationSessionId: "fresh-runner", + established: false, + bootstrapTurnIntentId: "intent-1", + }); + }); + it("deletes an unestablished runner before accepting a different intent", async () => { saveContinuation("scope", "root", { continuationSessionId: "runner-pending", diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts index d03fbadf46..991457d502 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts @@ -199,6 +199,12 @@ export interface RunConversationTurnParams { executionScopeKey: string; /** Stable logical id for durable redelivery; minted for ordinary chat. */ turnIntentId?: string; + /** + * A durable caller may bind this runner before transport send. In that + * mode a send error must escape to the caller instead of silently rolling + * to a different runner under the same fenced claim. + */ + preserveRunnerOnTransportFailure?: boolean; /** * Called when the reusable runner and exact runtime intent are known. */ @@ -207,6 +213,11 @@ export interface RunConversationTurnParams { turnId: string, turnIntentId: string ) => void | Promise; + /** Called after the adapter accepts the exact runtime intent. */ + onTurnAccepted?: ( + runnerSessionId: string, + turnIntentId: string + ) => void | Promise; /** * Fires after push #1 (the user's message row) lands on the plane — the * composer unblocks here; the agent tail streams in later under the same @@ -449,6 +460,7 @@ async function runConversationTurnSerialized( accountId: decision.record.accountId, }); } catch (error) { + if (params.preserveRunnerOnTransportFailure) throw error; log.warn("continuation send rejected; rolling to a fresh runner", error); clearContinuation(params.executionScopeKey, params.rootSessionId); await cleanupConversationRunnerBestEffort( @@ -461,6 +473,10 @@ async function runConversationTurnSerialized( userRowAlreadyPushed: true, }); } + await params.onTurnAccepted?.( + decision.record.continuationSessionId, + io.turnIntentId + ); return settleEpisode(params, io, { key, runnerSessionId: decision.record.continuationSessionId, @@ -633,8 +649,10 @@ async function dispatchBootstrapEpisode( accountId: episode.accountId, }); } catch (error) { - clearContinuation(params.executionScopeKey, params.rootSessionId); - await cleanupConversationRunnerBestEffort(episode.runnerSessionId); + if (!params.preserveRunnerOnTransportFailure) { + clearContinuation(params.executionScopeKey, params.rootSessionId); + await cleanupConversationRunnerBestEffort(episode.runnerSessionId); + } throw error; } if ( @@ -647,6 +665,7 @@ async function dispatchBootstrapEpisode( ) { log.warn("continuation acceptance could not be persisted"); } + await params.onTurnAccepted?.(episode.runnerSessionId, io.turnIntentId); return settleEpisode(params, io, { key, runnerSessionId: episode.runnerSessionId, diff --git a/src/features/Org2Cloud/SessionConversation/useWorkItemConversationTurnBridge.ts b/src/features/Org2Cloud/SessionConversation/useWorkItemConversationTurnBridge.ts new file mode 100644 index 0000000000..9c63eb5f1e --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useWorkItemConversationTurnBridge.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +import { installWorkItemConversationTurnBridge } from "./workItemConversationTurnBridge"; + +/** Mount once so background Work Item dispatches always find a listener. */ +export function useWorkItemConversationTurnBridge(): void { + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | null = null; + void installWorkItemConversationTurnBridge().then((dispose) => { + if (disposed) dispose(); + else unlisten = dispose; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, []); +} diff --git a/src/features/Org2Cloud/SessionConversation/workItemConversationTurnBridge.test.ts b/src/features/Org2Cloud/SessionConversation/workItemConversationTurnBridge.test.ts new file mode 100644 index 0000000000..6146182461 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/workItemConversationTurnBridge.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Org2CloudAuthState } from "../org2CloudAuthAtom"; +import { + type WorkItemConversationTurnDeps, + type WorkItemConversationTurnRequest, + WorkItemConversationTurnRequestSchema, + decideConversationTurnAcceptance, + handleWorkItemConversationTurnRequest, +} from "./workItemConversationTurnBridge"; + +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); +vi.mock("@src/api/tauri/rpc", () => ({ rpc: { workRuns: {} } })); +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); +vi.mock("../org2CloudCapabilities", () => ({ + getCloudCapabilitiesConfirmed: vi.fn(), +})); +vi.mock("../org2CloudProjectOrgAlias", () => ({ + resolveCloudOrgForProjectOrg: vi.fn(), +})); +vi.mock("./cloudConversationRuntime", () => ({ + getRequiredCloudAccessToken: vi.fn(), + loadCloudConversationInitialContext: vi.fn(), + loadCloudConversationPlaneDelta: vi.fn(), + registerCloudConversationRunner: vi.fn(), + settleCloudConversationRunner: vi.fn(), + signalCloudConversationPlane: vi.fn(), +})); +vi.mock("./conversationTurnRunner", () => ({ + runConversationTurn: vi.fn(), +})); + +const AUTH = { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example", + supabaseAnonKey: "anon", + userId: "user-b", + accessToken: "token", + refreshToken: "refresh", + expiresAt: 4_000_000_000, +} satisfies Org2CloudAuthState; + +const REQUEST: WorkItemConversationTurnRequest = { + runId: "run-1", + dispatchId: "dispatch-1", + claimToken: "claim-1", + orgId: "project-org", + projectSlug: "demo", + workItemId: "WI-0001", + workItemTitle: "Probe item", + assignedAgentId: "agent-assigned", + rootSessionId: "root-remote", + preparedRunnerSessionId: "runner-prepared", + content: "[Work Item Discussion]\n\nplease retry", + displayText: "💬 please retry", + discussionCommentIds: ["c1"], +}; + +type TurnParams = Parameters[0]; + +function makeDeps( + overrides: Partial = {} +): WorkItemConversationTurnDeps & { + calls: string[]; + turnParams: TurnParams[]; +} { + const calls: string[] = []; + const turnParams: TurnParams[] = []; + const deps: WorkItemConversationTurnDeps & { + calls: string[]; + turnParams: TurnParams[]; + } = { + calls, + turnParams, + getAuth: () => AUTH, + getAccessToken: async () => "token", + resolveCloudOrg: async () => "cloud-org", + probeCapabilities: async () => ({ + conversationEvents: true, + conversationEventsIdempotency: true, + confirmed: true, + }), + accept: async (runId, claimToken) => { + calls.push(`accept:${runId}:${claimToken}`); + return claimToken; + }, + release: async (runId, claimToken) => { + calls.push(`release:${runId}:${claimToken}`); + return true; + }, + nack: async (runId, claimToken, reason) => { + calls.push(`nack:${runId}:${claimToken}:${reason}`); + return true; + }, + prepareRunner: async ( + runId, + claimToken, + rootSessionId, + runnerSessionId + ) => { + calls.push( + `prepare:${runId}:${claimToken}:${rootSessionId}:${runnerSessionId}` + ); + }, + ackRunner: async (runId, claimToken, rootSessionId, runnerSessionId) => { + calls.push( + `ack:${runId}:${claimToken}:${rootSessionId}:${runnerSessionId}` + ); + }, + lookupRoot: () => ({ + title: "Root session", + repoScopeKey: "scope-key", + model: "model-x", + }), + loadInitialContext: async () => ({ + timeline: [], + readThroughPlaneSeq: 5, + }), + loadPlaneDelta: async (_orgId, _rootSessionId, afterSeq) => ({ + events: [], + lastSeq: afterSeq, + }), + runTurn: async (params) => { + turnParams.push(params); + await params.onRunnerReady?.( + "runner-1", + params.turnIntentId ?? "missing", + params.turnIntentId ?? "missing" + ); + await params.onTurnAccepted?.( + "runner-1", + params.turnIntentId ?? "missing" + ); + params.onPushed?.(); + return { + runnerSessionId: "runner-1", + pushedEventCount: 2, + turnIntentId: params.turnIntentId ?? "missing", + terminalStatus: "completed", + }; + }, + registerRunner: ({ orgId, rootSessionId, runnerSessionId }) => { + calls.push(`runner:${orgId}:${rootSessionId}:${runnerSessionId}`); + }, + settleRunner: (rootSessionId, runnerSessionId) => { + calls.push(`settle:${rootSessionId}:${runnerSessionId}`); + }, + signalPlane: (orgId) => calls.push(`signal:${orgId}`), + ...overrides, + }; + return deps; +} + +describe("decideConversationTurnAcceptance", () => { + it("requires signed-in retry-safe conversation capability", () => { + expect( + decideConversationTurnAcceptance({ + signedIn: true, + cloudOrgId: "cloud-org", + capabilities: { + conversationEvents: true, + conversationEventsIdempotency: true, + confirmed: true, + }, + }) + ).toEqual({ accepted: true, cloudOrgId: "cloud-org" }); + expect( + decideConversationTurnAcceptance({ + signedIn: true, + cloudOrgId: "cloud-org", + capabilities: { + conversationEvents: true, + conversationEventsIdempotency: false, + confirmed: true, + }, + }) + ).toEqual({ + accepted: false, + reason: "cloud backend lacks retry-safe conversation writes", + }); + }); + + it("abstains while signed out or the cloud alias is absent", () => { + expect( + decideConversationTurnAcceptance({ + signedIn: false, + cloudOrgId: "cloud-org", + capabilities: null, + }).accepted + ).toBe(false); + expect( + decideConversationTurnAcceptance({ + signedIn: true, + cloudOrgId: null, + capabilities: null, + }).accepted + ).toBe(false); + }); +}); + +describe("handleWorkItemConversationTurnRequest", () => { + it("prepares, accepts and acknowledges through the shared runner", async () => { + const deps = makeDeps(); + + await expect( + handleWorkItemConversationTurnRequest(REQUEST, deps) + ).resolves.toBe("succeeded"); + expect(deps.calls).toEqual([ + "accept:run-1:claim-1", + "runner:cloud-org:root-remote:runner-1", + "prepare:run-1:claim-1:root-remote:runner-1", + "ack:run-1:claim-1:root-remote:runner-1", + "signal:cloud-org", + "settle:root-remote:runner-1", + ]); + expect(deps.turnParams[0]).toMatchObject({ + orgId: "cloud-org", + rootSessionId: "root-remote", + turnIntentId: "run-1", + displayText: "💬 please retry", + agentContent: REQUEST.content, + conversationTitle: "Root session", + sourceScopeKey: "scope-key", + sourceModel: "model-x", + assignedAgentDefinitionId: "agent-assigned", + preserveRunnerOnTransportFailure: true, + }); + expect(deps.turnParams[0].executionScopeKey).toBe( + JSON.stringify([ + "cloud-conversation-executor", + "https://cloud.example|user-b", + "cloud-org", + ]) + ); + }); + + it("does not claim from an incapable window", async () => { + const signedOut = makeDeps({ getAuth: () => null }); + await expect( + handleWorkItemConversationTurnRequest(REQUEST, signedOut) + ).resolves.toBe("declined"); + expect(signedOut.calls).toEqual([]); + + const legacy = makeDeps({ + probeCapabilities: async () => ({ + conversationEvents: true, + conversationEventsIdempotency: false, + confirmed: true, + }), + }); + await expect( + handleWorkItemConversationTurnRequest(REQUEST, legacy) + ).resolves.toBe("declined"); + expect(legacy.calls).toEqual([]); + }); + + it("does not execute when another window wins the Rust claim", async () => { + const deps = makeDeps({ + accept: async (runId, claimToken) => { + deps.calls.push(`accept:${runId}:${claimToken}`); + return null; + }, + }); + await expect( + handleWorkItemConversationTurnRequest(REQUEST, deps) + ).resolves.toBe("not_claimed"); + expect(deps.turnParams).toEqual([]); + }); + + it("nacks failures that occur before transport acceptance", async () => { + const deps = makeDeps({ + prepareRunner: async () => { + throw new Error("PM_RUN_ERR:INVALID_TRANSITION"); + }, + }); + await expect( + handleWorkItemConversationTurnRequest(REQUEST, deps) + ).resolves.toBe("failed"); + expect(deps.calls).toEqual([ + "accept:run-1:claim-1", + "runner:cloud-org:root-remote:runner-1", + "nack:run-1:claim-1:PM_RUN_ERR:INVALID_TRANSITION", + "settle:root-remote:runner-1", + ]); + }); + + it("keeps execution alive and releases when ack transport fails", async () => { + const deps = makeDeps({ + ackRunner: async (runId, claimToken, rootSessionId, runnerSessionId) => { + deps.calls.push( + `ack:${runId}:${claimToken}:${rootSessionId}:${runnerSessionId}` + ); + throw new Error("ack response lost"); + }, + }); + await expect( + handleWorkItemConversationTurnRequest(REQUEST, deps) + ).resolves.toBe("succeeded"); + expect(deps.calls).toEqual([ + "accept:run-1:claim-1", + "runner:cloud-org:root-remote:runner-1", + "prepare:run-1:claim-1:root-remote:runner-1", + "ack:run-1:claim-1:root-remote:runner-1", + "signal:cloud-org", + "release:run-1:claim-1", + "settle:root-remote:runner-1", + ]); + }); + + it("falls back to release when durable nack itself fails", async () => { + const deps = makeDeps({ + runTurn: async () => { + throw new Error("network unavailable"); + }, + nack: async (runId, claimToken, reason) => { + deps.calls.push(`nack:${runId}:${claimToken}:${reason}`); + throw new Error("nack unavailable"); + }, + }); + await expect( + handleWorkItemConversationTurnRequest(REQUEST, deps) + ).resolves.toBe("failed"); + expect(deps.calls).toEqual([ + "accept:run-1:claim-1", + "nack:run-1:claim-1:network unavailable", + "release:run-1:claim-1", + ]); + }); +}); + +describe("WorkItemConversationTurnRequestSchema", () => { + it("parses the backend payload and defaults comment ids", () => { + const parsed = WorkItemConversationTurnRequestSchema.parse({ + runId: "run", + dispatchId: "dispatch", + claimToken: "claim", + orgId: "org", + workItemId: "WI-1", + rootSessionId: "root", + content: "body", + }); + expect(parsed.discussionCommentIds).toEqual([]); + expect(parsed.assignedAgentId).toBeUndefined(); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/workItemConversationTurnBridge.ts b/src/features/Org2Cloud/SessionConversation/workItemConversationTurnBridge.ts new file mode 100644 index 0000000000..5857ade140 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/workItemConversationTurnBridge.ts @@ -0,0 +1,410 @@ +/** Frontend listener for durable Work Item turns targeting a remote root. */ +import { listen } from "@tauri-apps/api/event"; +import { z } from "zod/v4"; + +import { rpc } from "@src/api/tauri/rpc"; +import { createLogger } from "@src/hooks/logger"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; + +import { + type Org2CloudAuthState, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "../org2CloudAuthAtom"; +import { getCloudCapabilitiesConfirmed } from "../org2CloudCapabilities"; +import { resolveCloudOrgForProjectOrg } from "../org2CloudProjectOrgAlias"; +import { org2CloudRemoteSessionsAtom } from "../org2CloudRemoteSessionsAtom"; +import { + getRequiredCloudAccessToken, + loadCloudConversationInitialContext, + loadCloudConversationPlaneDelta, + registerCloudConversationRunner, + settleCloudConversationRunner, + signalCloudConversationPlane, +} from "./cloudConversationRuntime"; +import { + cloudConversationExecutorScopeKey, + cloudConversationSetupMemoryKey, +} from "./conversationExecutionStore"; +import { + type ConversationInitialContext, + type RunConversationTurnParams, + type RunConversationTurnResult, + runConversationTurn, +} from "./conversationTurnRunner"; + +const log = createLogger("WorkItemConversationTurnBridge"); + +export const WORK_ITEM_CONVERSATION_TURN_EVENT = + "orgii-work-run-conversation-turn"; + +export const WorkItemConversationTurnRequestSchema = z.object({ + runId: z.string().min(1), + dispatchId: z.string().min(1), + claimToken: z.string().min(1), + orgId: z.string().min(1), + projectSlug: z.string().nullish(), + workItemId: z.string().min(1), + workItemTitle: z.string().nullish(), + assignedAgentId: z.string().nullish(), + rootSessionId: z.string().min(1), + preparedRunnerSessionId: z.string().min(1).nullish(), + content: z.string().min(1), + displayText: z.string().nullish(), + discussionCommentIds: z.array(z.string()).default([]), +}); + +export type WorkItemConversationTurnRequest = z.infer< + typeof WorkItemConversationTurnRequestSchema +>; + +export interface ConversationTurnCapabilityProbe { + conversationEvents: boolean; + conversationEventsIdempotency: boolean; + confirmed: boolean; +} + +export type ConversationTurnAcceptance = + | { accepted: true; cloudOrgId: string } + | { accepted: false; reason: string }; + +export function decideConversationTurnAcceptance(input: { + signedIn: boolean; + cloudOrgId: string | null; + capabilities: ConversationTurnCapabilityProbe | null; +}): ConversationTurnAcceptance { + if (!input.signedIn) { + return { accepted: false, reason: "cloud sign-in required" }; + } + if (!input.cloudOrgId) { + return { + accepted: false, + reason: "work item org is not synced to a cloud org", + }; + } + if (!input.capabilities?.confirmed) { + return { accepted: false, reason: "cloud capabilities unavailable" }; + } + if (!input.capabilities.conversationEvents) { + return { + accepted: false, + reason: "cloud backend lacks conversation events", + }; + } + if (!input.capabilities.conversationEventsIdempotency) { + return { + accepted: false, + reason: "cloud backend lacks retry-safe conversation writes", + }; + } + return { accepted: true, cloudOrgId: input.cloudOrgId }; +} + +export interface ConversationRootHint { + title?: string; + repoScopeKey?: string; + model?: string; +} + +export interface WorkItemConversationTurnDeps { + getAuth: () => Org2CloudAuthState | null; + getAccessToken: () => Promise; + resolveCloudOrg: (projectOrgId: string) => Promise; + probeCapabilities: ( + accessToken: string + ) => Promise; + accept: (runId: string, claimToken: string) => Promise; + release: (runId: string, claimToken: string) => Promise; + nack: (runId: string, claimToken: string, reason: string) => Promise; + prepareRunner: ( + runId: string, + claimToken: string, + rootSessionId: string, + runnerSessionId: string + ) => Promise; + ackRunner: ( + runId: string, + claimToken: string, + rootSessionId: string, + runnerSessionId: string + ) => Promise; + lookupRoot: ( + cloudOrgId: string, + rootSessionId: string + ) => ConversationRootHint; + loadInitialContext: (params: { + orgId: string; + rootSessionId: string; + streamSessionId: string; + excludeTurnIntentId: string; + }) => Promise; + loadPlaneDelta: ( + orgId: string, + rootSessionId: string, + afterSeq: number + ) => ReturnType; + runTurn: ( + params: RunConversationTurnParams + ) => Promise; + registerRunner: typeof registerCloudConversationRunner; + settleRunner: typeof settleCloudConversationRunner; + signalPlane: typeof signalCloudConversationPlane; +} + +export type WorkItemConversationTurnOutcome = + | "declined" + | "not_claimed" + | "succeeded" + | "failed"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function releaseClaimBestEffort( + deps: WorkItemConversationTurnDeps, + runId: string, + claimToken: string +): Promise { + try { + await deps.release(runId, claimToken); + } catch (error) { + log.warn(`failed to release conversation claim for run ${runId}`, error); + } +} + +export async function handleWorkItemConversationTurnRequest( + request: WorkItemConversationTurnRequest, + deps: WorkItemConversationTurnDeps +): Promise { + const auth = deps.getAuth(); + let accessToken: string | null = null; + let cloudOrgId: string | null = null; + let capabilities: ConversationTurnCapabilityProbe | null = null; + if (auth) { + try { + accessToken = await deps.getAccessToken(); + cloudOrgId = await deps.resolveCloudOrg(request.orgId); + if (cloudOrgId) { + capabilities = await deps.probeCapabilities(accessToken); + } + } catch (error) { + log.warn(`acceptance probe failed for run ${request.runId}`, error); + } + } + const decision = decideConversationTurnAcceptance({ + signedIn: Boolean(accessToken), + cloudOrgId, + capabilities, + }); + if (!decision.accepted) { + // Every app window receives the event. Incapable windows abstain so they + // cannot beat a capable window to the Rust claim boundary. + log.info( + `conversation turn ${request.runId} abstained: ${decision.reason}` + ); + return "declined"; + } + + const claimToken = await deps.accept(request.runId, request.claimToken); + if (!claimToken) return "not_claimed"; + + const resolvedOrgId = decision.cloudOrgId; + const rootSessionId = request.rootSessionId; + const authIdentity = org2CloudAuthIdentityKey(auth as Org2CloudAuthState); + const executionScopeKey = cloudConversationExecutorScopeKey( + authIdentity, + resolvedOrgId + ); + let runnerSessionId: string | null = null; + let transportAccepted = false; + let acknowledged = false; + let failure: unknown = null; + + try { + const root = deps.lookupRoot(resolvedOrgId, rootSessionId); + const result = await deps.runTurn({ + getAccessToken: deps.getAccessToken, + orgId: resolvedOrgId, + rootSessionId, + conversationTitle: + root.title ?? request.workItemTitle ?? request.workItemId, + displayText: request.displayText ?? request.content, + agentContent: request.content, + loadInitialContext: (excludeTurnIntentId) => + deps.loadInitialContext({ + orgId: resolvedOrgId, + rootSessionId, + streamSessionId: rootSessionId, + excludeTurnIntentId, + }), + loadPlaneDelta: (afterSeq) => + deps.loadPlaneDelta(resolvedOrgId, rootSessionId, afterSeq), + sourceScopeKey: root.repoScopeKey, + sourceModel: root.model, + assignedAgentDefinitionId: request.assignedAgentId ?? undefined, + setupMemoryKey: cloudConversationSetupMemoryKey( + authIdentity, + resolvedOrgId, + rootSessionId, + request.assignedAgentId ?? undefined + ), + executionScopeKey, + turnIntentId: request.runId, + preserveRunnerOnTransportFailure: true, + onRunnerReady: async (createdRunnerId, turnId, turnIntentId) => { + runnerSessionId = createdRunnerId; + deps.registerRunner({ + orgId: resolvedOrgId, + rootSessionId, + runnerSessionId: createdRunnerId, + turnId, + turnIntentId, + }); + await deps.prepareRunner( + request.runId, + claimToken, + rootSessionId, + createdRunnerId + ); + }, + onTurnAccepted: async (acceptedRunnerId) => { + transportAccepted = true; + try { + await deps.ackRunner( + request.runId, + claimToken, + rootSessionId, + acceptedRunnerId + ); + acknowledged = true; + } catch (error) { + // The exact runtime intent is already durable. Keep its wait/tail + // pipeline alive and release the claim for backend reconciliation. + log.warn( + `durable ack failed after runtime acceptance for run ${request.runId}`, + error + ); + } + }, + onPushed: () => deps.signalPlane(resolvedOrgId), + }); + return result.terminalStatus === "completed" ? "succeeded" : "failed"; + } catch (error) { + failure = error; + log.error(`conversation turn failed for run ${request.runId}`, error); + return "failed"; + } finally { + if (!acknowledged) { + if (failure && !transportAccepted) { + try { + await deps.nack(request.runId, claimToken, errorMessage(failure)); + } catch (error) { + log.warn(`durable nack failed for run ${request.runId}`, error); + await releaseClaimBestEffort(deps, request.runId, claimToken); + } + } else { + await releaseClaimBestEffort(deps, request.runId, claimToken); + } + } + if (runnerSessionId) { + deps.settleRunner(rootSessionId, runnerSessionId); + } + } +} + +export function createWorkItemConversationTurnDeps(): WorkItemConversationTurnDeps { + const store = getInstrumentedStore(); + return { + getAuth: () => store.get(org2CloudAuthAtom), + getAccessToken: getRequiredCloudAccessToken, + resolveCloudOrg: resolveCloudOrgForProjectOrg, + probeCapabilities: async (accessToken) => { + const probe = await getCloudCapabilitiesConfirmed(accessToken); + return { + conversationEvents: probe.capabilities.conversationEvents, + conversationEventsIdempotency: Boolean( + probe.capabilities.conversationEventsIdempotency + ), + confirmed: probe.confirmed, + }; + }, + accept: (runId, claimToken) => + rpc.workRuns.conversationTurnAccept({ + runId, + claimToken, + accepted: true, + }), + release: (runId, claimToken) => + rpc.workRuns.conversationTurnRelease({ runId, claimToken }), + nack: (runId, claimToken, reason) => + rpc.workRuns.conversationTurnNack({ runId, claimToken, reason }), + prepareRunner: async ( + runId, + claimToken, + rootSessionId, + runnerSessionId + ) => { + await rpc.workRuns.conversationTurnPrepareRunner({ + runId, + claimToken, + rootSessionId, + runnerSessionId, + }); + }, + ackRunner: async (runId, claimToken, rootSessionId, runnerSessionId) => { + await rpc.workRuns.conversationTurnAckRunner({ + runId, + claimToken, + rootSessionId, + runnerSessionId, + }); + }, + lookupRoot: (cloudOrgId, rootSessionId) => { + const row = store + .get(org2CloudRemoteSessionsAtom) + [ + cloudOrgId + ]?.rows.find((candidate) => candidate.sourceSessionId === rootSessionId); + return { + title: row?.title, + repoScopeKey: row?.repoScopeKey, + model: row?.model, + }; + }, + loadInitialContext: loadCloudConversationInitialContext, + loadPlaneDelta: loadCloudConversationPlaneDelta, + runTurn: runConversationTurn, + registerRunner: registerCloudConversationRunner, + settleRunner: settleCloudConversationRunner, + signalPlane: signalCloudConversationPlane, + }; +} + +/** Install once at the app root; Rust remains the cross-window claimant. */ +export async function installWorkItemConversationTurnBridge( + deps: WorkItemConversationTurnDeps = createWorkItemConversationTurnDeps() +): Promise<() => void> { + const inFlight = new Set(); + return listen(WORK_ITEM_CONVERSATION_TURN_EVENT, (event) => { + const parsed = WorkItemConversationTurnRequestSchema.safeParse( + event.payload + ); + if (!parsed.success) { + log.warn("ignoring malformed conversation turn request", parsed.error); + return; + } + const request = parsed.data; + const offerKey = JSON.stringify([request.runId, request.claimToken]); + if (inFlight.has(offerKey)) return; + inFlight.add(offerKey); + void handleWorkItemConversationTurnRequest(request, deps) + .then((outcome) => + log.info(`conversation turn ${request.runId}: ${outcome}`) + ) + .catch((error) => + log.error(`conversation turn ${request.runId} crashed`, error) + ) + .finally(() => inFlight.delete(offerKey)); + }); +} diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts index 55b82b6c77..80a65c295f 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts @@ -343,6 +343,17 @@ describe("getCloudCapabilitiesConfirmed", () => { expect(rawMock).toHaveBeenCalledTimes(2); }); + it("surfaces the retry-safe conversation projection capability", async () => { + rawMock.mockResolvedValueOnce({ + conversationEvents: true, + conversationEventsIdempotency: true, + }); + const result = await getCloudCapabilitiesConfirmed("jwt-1"); + expect(result.confirmed).toBe(true); + expect(result.capabilities.conversationEvents).toBe(true); + expect(result.capabilities.conversationEventsIdempotency).toBe(true); + }); + it("shares the same per-endpoint cache as getCloudCapabilities", async () => { rawMock.mockResolvedValueOnce({ broadcastSignals: true }); expect(await getCloudCapabilities("jwt-1")).toEqual({ diff --git a/src/features/Org2Cloud/org2CloudCapabilities.ts b/src/features/Org2Cloud/org2CloudCapabilities.ts index 2a98c7cdd0..72d26db90d 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.ts @@ -28,6 +28,7 @@ const CloudCapabilitiesWireSchema = z.object({ orgChannelMessages: z.boolean().nullish().catch(undefined), orgChannelMessagesIdempotency: z.boolean().nullish().catch(undefined), conversationEvents: z.boolean().nullish().catch(undefined), + conversationEventsIdempotency: z.boolean().nullish().catch(undefined), }); export interface CloudCapabilities { @@ -49,6 +50,8 @@ export interface CloudCapabilities { orgChannelMessagesIdempotency: boolean; /** 0024 multi-writer conversation-events plane (push/list RPCs). */ conversationEvents: boolean; + /** 0026 retry-safe event identity/projection contract. */ + conversationEventsIdempotency?: boolean; } const LEGACY_CAPABILITIES: CloudCapabilities = { @@ -116,6 +119,9 @@ async function probeCloudCapabilities( orgChannelMessagesIdempotency: parsed.data.orgChannelMessagesIdempotency ?? false, conversationEvents: parsed.data.conversationEvents ?? false, + ...(parsed.data.conversationEventsIdempotency + ? { conversationEventsIdempotency: true } + : {}), }; capabilitiesByEndpoint.set(endpointKey, capabilities); return { capabilities, confirmed: true }; diff --git a/src/router/index.tsx b/src/router/index.tsx index be7664ba1a..7b1f29b1ca 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -2,6 +2,7 @@ import { registerAppActions } from "@/src/ActionSystem/registerAppActions"; import { useEffect } from "react"; import { Outlet, createBrowserRouter, useNavigate } from "react-router-dom"; +import { useWorkItemConversationTurnBridge } from "@src/features/Org2Cloud/SessionConversation/useWorkItemConversationTurnBridge"; import { useOrg2CloudOrgs } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; import { useOrg2CloudRosterReconcile } from "@src/features/Org2Cloud/org2CloudRosterReconcile"; import { useOrg2CloudGuestShareAccess } from "@src/features/Org2Cloud/useOrg2CloudGuestShareAccess"; @@ -53,6 +54,9 @@ const RootLayout = () => { // Inbound Realtime: roster / projects / work-items / comments // subscriptions replace 60s polling as the primary inbound trigger. useOrg2CloudRealtime(); + // Work Item comments targeting another member's root execute through this + // device's persistent local conversation continuation. + useWorkItemConversationTurnBridge(); // Registered non-member imports remain readable only while their persisted // share capability is valid; revoked links evict the durable replay. useOrg2CloudGuestShareAccess();