diff --git a/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts b/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts index b3be2d68c35..49435608791 100644 --- a/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts +++ b/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts @@ -301,6 +301,11 @@ describe('stripMainOnlySendOpts(直连路径消毒)', () => { .toEqual({ messageUuid: 'u' }); }); + it('剥掉客户端自报的 fromDeviceLinkClient', () => { + expect(stripMainOnlySendOpts({ messageUuid: 'u', fromDeviceLinkClient: true })) + .toEqual({ messageUuid: 'u' }); + }); + it('剥掉客户端伪造的 generation 与 turn 身份,但保留待 IPC 校验的 clear token', () => { expect( stripMainOnlySendOpts({ @@ -387,11 +392,22 @@ describe('排队 / 插入两条路径的接线(源码级守卫)', () => { expect(register).toContain('isMobileControllerInvoke(),'); }); + it('device-link provenance is stamped at both queue input boundaries', () => { + const stamps = register.match(/stampTrustedDeviceLinkQueuedOrigin\(/g) ?? []; + expect(stamps.length).toBe(2); + expect(register).toContain('deviceLinkInvoke,'); + }); + it('coordinator 在 drain 与 steer 两处都透传', () => { const passes = coordinator.match(/fromMobileClient: true \} : \{\}\)/g) ?? []; expect(passes.length).toBe(2); }); + it('coordinator drain carries device-link provenance into the send transaction', () => { + expect(coordinator).toContain('fromDeviceLinkClient: true } : {})'); + expect(transaction).toContain('requestedSendOpts.fromDeviceLinkClient === true'); + }); + it('send 事务认 async context 与透传值两个来源', () => { expect(transaction).toContain( "deps.isMobileClientInvoke?.() === true || so.fromMobileClient === true", diff --git a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts index 2ea86f6d5b0..0741b2a535b 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts @@ -51,6 +51,24 @@ describe('AgentInputCoordinator Orca priority queue transactions', () => { origin: { kind: 'orca', senderLabel: 'Lead', displayText: text }, }); + it('forwards main-stamped device-link provenance from enqueue to send', async () => { + const h = createHarness(); + const sid = 'device-link-lead'; + await h.coordinator.ensureQueueRestored(sid); + + h.coordinator.enqueue(sid, makeItem('device-link-input', 'hello', { + fromDeviceLinkClient: true, + })); + await flush(); + + expect(h.sendToAgent).toHaveBeenCalledWith( + sid, + expect.anything(), + expect.anything(), + expect.objectContaining({ fromDeviceLinkClient: true }), + ); + }); + it('restores first, reserves at the head with a host stamp, deduplicates, and emits once', async () => { const h = createHarness(); const sid = 'priority-worker'; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts index 460c8caf3eb..e18dd1f1a3d 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createMakerSendTransaction, restoreTrustedDesktopQueuedOrigin, + stampTrustedDeviceLinkQueuedOrigin, stampTrustedDesktopQueuedOrigin, TRUSTED_DESKTOP_PI_COMMAND_SNAPSHOT, TRUSTED_DESKTOP_QUEUE_ORIGIN, @@ -85,6 +86,17 @@ function createDeps(overrides: Partial = {}) { } describe('maker SEND transaction', () => { + it('stamps device-link provenance at the enqueue boundary and rejects forged local values', () => { + const item = { clientId: 'input-1', text: 'hello' } as unknown as AgentInputQueuedMessage; + expect(stampTrustedDeviceLinkQueuedOrigin(item, true)).toMatchObject({ + fromDeviceLinkClient: true, + }); + expect(stampTrustedDeviceLinkQueuedOrigin({ + ...item, + fromDeviceLinkClient: true, + }, false)).not.toHaveProperty('fromDeviceLinkClient'); + }); + it('rejects invalid sessionId before touching transaction dependencies', async () => { const { deps } = createDeps(); const transaction = createMakerSendTransaction(deps); @@ -1466,6 +1478,160 @@ describe('maker SEND transaction', () => { expect(newSession.send).toHaveBeenCalled(); }); + it('drops a DB sdk id for a fresh remote Codex Lead whose old runtime accepted no turn', async () => { + const oldSession = createSession({ + id: 'orca-session', + workDir: 'C:\\repo', + codexThreadMayHaveRollout: false, + }); + const newSession = createSession({ id: 'orca-session', workDir: 'C:\\repo' }); + const reconcileCreateOptsWithDb = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => { + co.resumeSessionId = 'fresh-thread-id'; + }); + const { deps } = createDeps({ + getSession: vi.fn(() => oldSession), + isOrcaMcpHydrated: vi.fn(() => false), + synthesizeOrcaVendorOptionsFromDb: vi.fn(async () => true), + reconcileCreateOptsWithDb, + bootstrapSession: vi.fn(async (opts: MakerSessionCreateOpts) => ({ + session: newSession, + didInjectOrcaInstructions: true, + didInjectProjectContext: false, + })), + }); + const transaction = createMakerSendTransaction(deps); + + await expect(transaction.sendToAgentAccepted('orca-session', 'hello', { + id: 'orca-session', + agentKind: 'codex', + workingDir: 'C:\\repo', + model: 'gpt-5.4', + remoteHostId: null, + orcaRole: 'lead', + }, { fromDeviceLinkClient: true })).resolves.toMatchObject({ accepted: true }); + + expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({ + resumeSessionId: undefined, + })); + expect(reconcileCreateOptsWithDb.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.closeSession).mock.invocationCallOrder[0]!, + ); + expect(vi.mocked(deps.closeSession).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.bootstrapSession).mock.invocationCallOrder[0]!, + ); + expect(vi.mocked(deps.bootstrapSession).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(newSession.send).mock.invocationCallOrder[0]!, + ); + expect(oldSession.send).not.toHaveBeenCalled(); + expect(newSession.send).toHaveBeenCalled(); + expect(deps.log.info).toHaveBeenCalledWith( + 'send: fresh remote Codex Lead rehydrate starts a new thread', + { evidence: 'no-provider-turn-accepted' }, + ); + }); + + type ResumePreservationScenario = { + fromDeviceLinkClient: boolean; + codexThreadMayHaveRollout?: boolean; + orcaRole?: 'lead' | 'worker'; + remoteHostId: string | null; + }; + const resumePreservationScenarios: Array<[string, ResumePreservationScenario]> = [ + ['device-link Lead with true evidence', { fromDeviceLinkClient: true, codexThreadMayHaveRollout: true, orcaRole: 'lead' as const, remoteHostId: null }], + ['device-link Lead with unknown evidence', { fromDeviceLinkClient: true, orcaRole: 'lead' as const, remoteHostId: null }], + ['device-link Worker', { fromDeviceLinkClient: true, codexThreadMayHaveRollout: false, orcaRole: 'worker' as const, remoteHostId: null }], + ['device-link non-Orca session', { fromDeviceLinkClient: true, codexThreadMayHaveRollout: false, orcaRole: undefined, remoteHostId: null }], + ['local ordinary Lead', { fromDeviceLinkClient: false, codexThreadMayHaveRollout: false, orcaRole: 'lead' as const, remoteHostId: null }], + ['SSH historical Lead', { fromDeviceLinkClient: false, codexThreadMayHaveRollout: true, orcaRole: 'lead' as const, remoteHostId: 'ssh-host' }], + ]; + it.each(resumePreservationScenarios)('preserves the DB sdk id for %s', async (_name, scenario) => { + const oldSession = createSession({ + id: 'orca-session', + workDir: 'C:\\repo', + ...(scenario.remoteHostId ? { remoteHostId: scenario.remoteHostId } : {}), + ...(scenario.codexThreadMayHaveRollout === undefined + ? {} + : { codexThreadMayHaveRollout: scenario.codexThreadMayHaveRollout }), + }); + const newSession = createSession({ + id: 'orca-session', + workDir: 'C:\\repo', + ...(scenario.remoteHostId ? { remoteHostId: scenario.remoteHostId } : {}), + }); + const reconcileCreateOptsWithDb = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => { + co.resumeSessionId = 'historical-thread-id'; + }); + const { deps } = createDeps({ + getSession: vi.fn(() => oldSession), + isOrcaMcpHydrated: vi.fn(() => false), + synthesizeOrcaVendorOptionsFromDb: vi.fn(async () => true), + reconcileCreateOptsWithDb, + bootstrapSession: vi.fn(async () => ({ + session: newSession, + didInjectOrcaInstructions: true, + didInjectProjectContext: false, + })), + }); + const transaction = createMakerSendTransaction(deps); + const createOpts: MakerSessionCreateOpts = { + id: 'orca-session', + agentKind: 'codex', + workingDir: 'C:\\repo', + model: 'gpt-5.4', + ...(scenario.remoteHostId ? { remoteHostId: scenario.remoteHostId } : {}), + ...(scenario.orcaRole ? { orcaRole: scenario.orcaRole } : {}), + }; + const sendOpts = scenario.fromDeviceLinkClient ? { fromDeviceLinkClient: true } : undefined; + + await expect(transaction.sendToAgentAccepted('orca-session', 'hello', createOpts, sendOpts)) + .resolves.toMatchObject({ accepted: true }); + + expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({ + resumeSessionId: 'historical-thread-id', + })); + }); + + it('keeps the DB sdk id for a remote Codex Lead whose old runtime accepted a rollout', async () => { + const oldSession = createSession({ + id: 'orca-session', + workDir: 'C:\\repo', + codexThreadMayHaveRollout: true, + }); + const newSession = createSession({ id: 'orca-session', workDir: 'C:\\repo' }); + const reconcileCreateOptsWithDb = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => { + co.resumeSessionId = 'historical-thread-id'; + }); + const { deps } = createDeps({ + getSession: vi.fn(() => oldSession), + isOrcaMcpHydrated: vi.fn(() => false), + synthesizeOrcaVendorOptionsFromDb: vi.fn(async () => true), + reconcileCreateOptsWithDb, + bootstrapSession: vi.fn(async () => ({ + session: newSession, + didInjectOrcaInstructions: true, + didInjectProjectContext: false, + })), + }); + const transaction = createMakerSendTransaction(deps); + + await expect(transaction.sendToAgentAccepted('orca-session', 'hello', { + id: 'orca-session', + agentKind: 'codex', + workingDir: 'C:\\repo', + model: 'gpt-5.4', + remoteHostId: null, + orcaRole: 'lead', + }, { fromDeviceLinkClient: true })).resolves.toMatchObject({ accepted: true }); + + expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({ + resumeSessionId: 'historical-thread-id', + })); + expect(deps.log.info).not.toHaveBeenCalledWith( + 'send: fresh remote Codex Lead rehydrate starts a new thread', + expect.anything(), + ); + }); + it('fails rehydrate without closing the old runtime when DB reconciliation throws (#2882)', async () => { const oldSession = createSession({ id: 'orca-session', workDir: 'C:\\repo' }); const { deps } = createDeps({ diff --git a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts index ce7429c423a..9e7b2655496 100644 --- a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts +++ b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts @@ -206,6 +206,8 @@ export interface AgentInputSendOpts { * 最终 wire 消息。**由 main 构造,不是 wire 输入。** */ fromMobileClient?: boolean; + /** Queue provenance stamped by the controlled desktop at device-link input IPC entry. */ + fromDeviceLinkClient?: boolean; /** Main-owned clear token captured when this input became active. */ expectedClearBoundaryMs?: number | null; /** Main-owned input generation captured before async preparation. */ @@ -3759,6 +3761,7 @@ export class AgentInputCoordinator { private toProjectedItem(item: AgentInputQueuedMessage): AgentInputQueuedMessage { const projected = { ...item }; delete projected.hostAcceptedAtMs; + delete projected.fromDeviceLinkClient; delete projected.trustedSessionReferenceContexts; delete projected.sessionReferencesRequireTrustedSnapshot; delete (projected as Record)[TRUSTED_DESKTOP_PI_COMMAND_SNAPSHOT]; @@ -4325,6 +4328,7 @@ export class AgentInputCoordinator { ...(head.origin?.kind === 'scheduler' ? { origin: head.origin } : {}), // 手机来源透传到 send 事务:drain 已脱离入队时的 async context。 ...(head.fromMobileClient ? { fromMobileClient: true } : {}), + ...(head.fromDeviceLinkClient ? { fromDeviceLinkClient: true } : {}), persistUserMessage: { clientId: head.clientId, content: head.persistedContent, diff --git a/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts b/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts index 868fac30c5b..e57cde8ee31 100644 --- a/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts +++ b/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts @@ -153,6 +153,21 @@ export function stampTrustedDesktopQueuedOrigin( } as unknown as AgentInputQueuedMessage; } +/** + * Stamp device-link provenance at the trusted input IPC boundary. The queue + * drains after that AsyncLocalStorage context has ended, so the marker must + * travel with the main-owned item into the send transaction. + */ +export function stampTrustedDeviceLinkQueuedOrigin( + item: AgentInputQueuedMessage, + deviceLinkInvoke: boolean, +): AgentInputQueuedMessage { + const stamped = { ...item }; + if (deviceLinkInvoke) stamped.fromDeviceLinkClient = true; + else delete stamped.fromDeviceLinkClient; + return stamped; +} + export function restoreTrustedDesktopQueuedOrigin(item: AgentInputQueuedMessage): AgentInputQueuedMessage { const queued = item as QueuedMessageWithDesktopAuthorization; const receipt = queued[TRUSTED_DESKTOP_PI_COMMAND_SNAPSHOT]; @@ -206,6 +221,8 @@ type MakerSendOptions = { * 入队时的 async context 早已结束,只靠 isMobileClientInvoke() 实际读不到来源。 */ fromMobileClient?: boolean; + /** Coordinator-transmitted provenance for device-link input.enqueue. */ + fromDeviceLinkClient?: boolean; persistUserMessage?: { clientId?: unknown; content?: unknown; @@ -266,6 +283,8 @@ export interface MakerSendTransactionSession { remoteHostId: string | null; /** Error sessions stay registered while their underlying handle cleanup is retried. */ getStatus?(): 'active' | 'aborting' | 'closed' | 'error'; + /** Codex host-owned evidence: a provider turn crossed acceptance on this runtime. */ + codexThreadMayHaveRollout?: boolean; isTurnRunning(): boolean; send(message: UserMessage | string, opts?: SessionSendOptions): Promise; } @@ -626,6 +645,7 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make async function rehydrateActiveOrcaSession( sessionId: string, createOpts: CreateOpts, + fromDeviceLinkClient: boolean, ): Promise { const okRehydrate = await ensureWorkDirWithDbFallback(sessionId, createOpts); if (!okRehydrate) { @@ -647,6 +667,24 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make // 中途 start_team 后丢失全部对话历史)。DB 读失败时 reconcile 抛错 → 落入下方 // REHYDRATE_FAILED,此时尚未 closeSession,旧 runtime 不受损。 await deps.reconcileCreateOptsWithDb?.(sessionId, createOpts); + // A newly created device-link Codex Lead has a real sdk_session_id as soon as + // thread/start returns, but that id is not resumable until a provider turn + // is accepted. The live Session is the only trustworthy local evidence at + // this boundary: generation 0 means no turn crossed provider acceptance. + // Keep the historical DB resume path for non-Orca sessions, workers, and + // already-used Leads. + if ( + fromDeviceLinkClient && + createOpts.agentKind === 'codex' && + createOpts.orcaRole === 'lead' && + createOpts.resumeSessionId && + oldSessionCodexThreadMayHaveRollout(deps.getSession(sessionId)) === false + ) { + createOpts.resumeSessionId = undefined; + deps.log.info('send: fresh remote Codex Lead rehydrate starts a new thread', { + evidence: 'no-provider-turn-accepted', + }); + } const session = await deps.withRehydrateCloseSuppressed(sessionId, async () => { await deps.closeSession(sessionId); // close 后重新 bootstrap,避免旧 SDK handle 缺 Orca MCP vendorOptions。 @@ -705,6 +743,12 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make } } + function oldSessionCodexThreadMayHaveRollout( + session: MakerSendTransactionSession | null | undefined, + ): boolean | undefined { + return session?.codexThreadMayHaveRollout; + } + async function lazyCreateSession( sessionId: string, createOpts: CreateOpts, @@ -788,6 +832,7 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make sendOpts, ): Promise { if (typeof sessionId !== 'string') throwIpcError('INVALID_PARAMS', 'sessionId required'); + const requestedSendOpts = (sendOpts ?? {}) as MakerSendOptions; // session-agent-switch:pending 切换在发送时刻生效(用户语义:「消息真正发出 // 去时才切」)。必须在 getSession 之前——apply 会 close 旧引擎的 live session, // 让下方走 lazy-create 按 DB 新值 spawn 新引擎。 @@ -835,7 +880,11 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make sessionId, }); } else { - const rehydrated = await rehydrateActiveOrcaSession(sessionId, co); + const rehydrated = await rehydrateActiveOrcaSession( + sessionId, + co, + requestedSendOpts.fromDeviceLinkClient === true, + ); if (rehydrated.kind === 'failure') return rehydrated.result; sess = rehydrated.session; } @@ -856,7 +905,6 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make if (sess.isTurnRunning()) { throwIpcError('SESSION_RUNNING', `Session ${sessionId} is already running a turn`); } - const requestedSendOpts = (sendOpts ?? {}) as MakerSendOptions; if ( requestedSendOpts.ackInterruptedTurnOnDispatch !== undefined && typeof requestedSendOpts.ackInterruptedTurnOnDispatch !== 'boolean' diff --git a/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts b/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts index f0292e6fb67..023351ac487 100644 --- a/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts +++ b/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts @@ -145,6 +145,7 @@ export function stripMainOnlySendOpts(sendOpts: unknown): unknown { const opts = sendOpts as Record; if ( !('fromMobileClient' in opts) && + !('fromDeviceLinkClient' in opts) && !('expectedInputGeneration' in opts) && !('expectedTurnSession' in opts) && !('expectedTurnGeneration' in opts) && @@ -156,6 +157,7 @@ export function stripMainOnlySendOpts(sendOpts: unknown): unknown { } const { fromMobileClient: _ignoredMobile, + fromDeviceLinkClient: _ignoredDeviceLink, expectedInputGeneration: _ignoredGeneration, expectedTurnSession: _ignoredTurnSession, expectedTurnGeneration: _ignoredTurnGeneration, diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index df8db4b956d..3897234f6a2 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -694,6 +694,7 @@ import { containsManagedAttachment, createMakerSendTransaction, prepareDirectoryGrantsForBootstrap, + stampTrustedDeviceLinkQueuedOrigin, stampTrustedDesktopQueuedOrigin, TRUSTED_DESKTOP_QUEUE_ORIGIN, } from './makerSendTransaction.js'; @@ -14873,10 +14874,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) assertCurrentInputGeneration(); // 手机来源在**入队这一刻**盖章:drain 派发时已脱离本 invoke 的 async context。 // 无条件覆盖 —— item 来自 wire,客户端自填的 fromMobileClient 一律不生效。 - const queued = stampTrustedDesktopQueuedOrigin( - stampMobileClientOrigin( - await hydrateQueuedAgentReferences(queuedWithAttachments), - isMobileControllerInvoke(), + const queued = stampTrustedDeviceLinkQueuedOrigin( + stampTrustedDesktopQueuedOrigin( + stampMobileClientOrigin( + await hydrateQueuedAgentReferences(queuedWithAttachments), + isMobileControllerInvoke(), + ), + deviceLinkInvoke, ), deviceLinkInvoke, ); @@ -15082,10 +15086,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const queuedWithAttachments = materialized.item as AgentInputQueuedMessage; assertCurrentInputGeneration(); // 与 enqueue 同:steer 投递也在本 invoke 的 async context 之外发生。 - const queued = stampTrustedDesktopQueuedOrigin( - stampMobileClientOrigin( - await hydrateQueuedAgentReferences(queuedWithAttachments), - isMobileControllerInvoke(), + const queued = stampTrustedDeviceLinkQueuedOrigin( + stampTrustedDesktopQueuedOrigin( + stampMobileClientOrigin( + await hydrateQueuedAgentReferences(queuedWithAttachments), + isMobileControllerInvoke(), + ), + deviceLinkInvoke, ), deviceLinkInvoke, ); diff --git a/apps/desktop/src/main/maker-ipc/sessionSendHandler.ts b/apps/desktop/src/main/maker-ipc/sessionSendHandler.ts index 0e4b0b3ad55..1fee34c13b9 100644 --- a/apps/desktop/src/main/maker-ipc/sessionSendHandler.ts +++ b/apps/desktop/src/main/maker-ipc/sessionSendHandler.ts @@ -47,7 +47,7 @@ export function registerMakerSessionSendHandler( const boundaryStamp = await deps.assertRemoteInputControlBoundary?.(sessionId, sendOpts); const mainOwnedBoundaryStamp = boundaryStamp && typeof boundaryStamp === 'object' ? boundaryStamp : undefined; - // sendOpts 来自 wire:剥掉只允许 main 写的字段(fromMobileClient 由 coordinator + // sendOpts 来自 wire:剥掉只允许 main 写的字段(fromMobileClient/fromDeviceLinkClient 由 coordinator // 从队列项透传;turnPermissionPolicy 只由 Main 的 IM dispatcher 构造),然后由 // main 覆盖 clear token + generation。旧控制端不带 token 也因此获得同样的 clear // 竞态保护,而不是在最终 fence 缺 precondition 时放行。 diff --git a/apps/desktop/src/shared/agentInputQueue.ts b/apps/desktop/src/shared/agentInputQueue.ts index f71d9b24c63..7447aaaf484 100644 --- a/apps/desktop/src/shared/agentInputQueue.ts +++ b/apps/desktop/src/shared/agentInputQueue.ts @@ -284,6 +284,8 @@ export interface AgentInputQueuedMessage { * 见 device-link/invoke-context 的可信度说明。 */ fromMobileClient?: boolean; + /** Main-owned provenance: this queue item entered through device-link input IPC. */ + fromDeviceLinkClient?: boolean; /** * 一次性跳过意识拦截钩(订阅槽①)。**预留字段,v1 无调用点置位**:当前 * 没有"强制发送"UI,被拦消息只能编辑后重发且重发仍会再审;未来落地 diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index abcfd11b015..206397657d4 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -1740,6 +1740,8 @@ export interface AgentSessionHandle { * 这是 thread 级冻结身份,不随 thread/settings/update 的模型切换改变。 */ readonly codexThreadModelProviderId?: string; + /** Codex-only: provider-owned proof that this thread has crossed a turn boundary. */ + readonly codexThreadMayHaveRollout?: boolean; /** Codex-only: 当前 host 的独立 Subagent 路由是否兼容 Cindy Codex 远程压缩。 */ readonly codexCindyRemoteCompactionCompatible?: boolean; /** diff --git a/packages/maker-core/src/agents/codex/index.test.ts b/packages/maker-core/src/agents/codex/index.test.ts index a9100239a58..01868e14bc9 100644 --- a/packages/maker-core/src/agents/codex/index.test.ts +++ b/packages/maker-core/src/agents/codex/index.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import { promises as fs } from 'node:fs'; import { applyPatch, formatPatch, parsePatch, reversePatch } from 'diff'; -import { CodexAgent } from './index.js'; +import { CodexAgent, isExactNoRolloutThreadResumeError } from './index.js'; import { Method } from './app-server/protocol.js'; import type { ThreadEventHandlers } from './app-server/host.js'; import { @@ -21167,12 +21167,132 @@ describe('CodexAgent steer', () => { describe('CodexAgent resume preparation', () => { const resumeSessionId = '123e4567-e89b-12d3-a456-426614174000'; + function exactNoRolloutError(threadId = resumeSessionId): Error { + const error = new Error( + `codex app-server thread/resume error -32600: no rollout found for thread id ${threadId}`, + ); + Object.assign(error, { code: -32600, data: undefined }); + return error; + } + + it('accepts only the canonical structured app-server no-rollout error', () => { + expect(isExactNoRolloutThreadResumeError(exactNoRolloutError(), resumeSessionId)).toBe(true); + expect(isExactNoRolloutThreadResumeError( + Object.assign(exactNoRolloutError(), { message: `${exactNoRolloutError().message}; caused by timeout` }), + resumeSessionId, + )).toBe(false); + expect(isExactNoRolloutThreadResumeError( + Object.assign(exactNoRolloutError(), { code: -32603 }), + resumeSessionId, + )).toBe(false); + expect(isExactNoRolloutThreadResumeError( + Object.assign(exactNoRolloutError(), { data: { reason: 'wrapped' } }), + resumeSessionId, + )).toBe(false); + }); + + it('does not fallback when a wrapper merely mentions no rollout', async () => { + const agent = new CodexAgent(createDeps()); + const host = installFakeHost(agent, (method) => { + if (method === Method.ThreadResume) { + const error = new Error( + `codex app-server thread/resume error -32600: resume failed: no rollout found for thread id ${resumeSessionId}`, + ); + Object.assign(error, { code: -32600, data: undefined }); + throw error; + } + return undefined; + }); + + await expect(agent.startSession({ + sessionId: 'session-wrapped-no-rollout', + model: 'gpt-5.4', + workingDir: '/repo', + resumeSessionId, + })).rejects.toThrow('Failed to resume Codex thread'); + + expect(host.request.mock.calls.filter(([method]) => method === Method.ThreadResume)).toHaveLength(1); + expect(host.request.mock.calls.filter(([method]) => method === Method.ThreadStart)).toHaveLength(0); + }); + + it('falls back to thread/start only when resume proves the thread has no rollout', async () => { + const dynamicTools = [{ + type: 'function' as const, + name: 'cindy_fixture__call_tool', + description: 'Fixture dynamic tool.', + inputSchema: { type: 'object' }, + deferLoading: false, + }]; + const listTools = vi.fn(() => dynamicTools); + const agent = new CodexAgent(createDeps({ systemPrompt: 'runtime developer instructions' }, { + codexHostDynamicToolProvider: { + listTools, + callTool: vi.fn(async () => undefined), + }, + })); + const host = installFakeHost(agent, (method) => { + if (method === Method.ThreadResume) throw exactNoRolloutError(); + return undefined; + }, { + codexCustomProviderRoutes: [{ + providerId: 'fixture-provider', + modelProviderId: 'fixture-model-provider', + capabilities: {}, + responseModels: ['fixture-model'], + }], + }); + + const handle = await agent.startSession({ + sessionId: 'session-fresh-resume-fallback', + model: 'fixture-model', + providerId: 'fixture-provider', + fastMode: true, + workingDir: '/device-link/repo', + userPrompt: 'user developer instructions', + vendorOptions: { orcaRole: 'lead' }, + resumeSessionId, + }); + + expect(host.request.mock.calls.filter(([method]) => method === Method.ThreadResume)).toHaveLength(1); + expect(host.request.mock.calls.filter(([method]) => method === Method.ThreadStart)).toHaveLength(1); + expect(handle.id).toBe('start-thread-id'); + const startParams = host.request.mock.calls.find(([method]) => method === Method.ThreadStart)?.[1] as { + cwd?: string; + model?: string; + modelProvider?: string; + serviceTier?: string; + developerInstructions?: string; + dynamicTools?: unknown[]; + config?: Record; + }; + expect(startParams).toMatchObject({ + cwd: '/device-link/repo', + model: 'fixture-model', + modelProvider: 'fixture-model-provider', + serviceTier: 'fast', + }); + expect(startParams.developerInstructions).toContain('runtime developer instructions'); + expect(startParams.developerInstructions).toContain('user developer instructions'); + expect(startParams.dynamicTools).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'cindy_fixture__call_tool' }), + ])); + expect(listTools).toHaveBeenCalledWith(expect.objectContaining({ + workingDir: '/device-link/repo', + providerId: 'fixture-provider', + vendorOptions: { orcaRole: 'lead' }, + })); + await handle.close(); + }); + it('does not call thread/resume when the host identifies an unsafe rollout', async () => { const prepareCodexResumeSession = vi.fn(async () => { throw new CodexResumePreparationBlockedError('rollout may still have a live writer'); }); const agent = new CodexAgent(createDeps({}, { prepareCodexResumeSession })); - const host = installFakeHost(agent); + const host = installFakeHost(agent, (method) => { + if (method === Method.ThreadResume) throw new Error('no rollout found for thread'); + return undefined; + }); await expect(agent.startSession({ sessionId: 'session-blocked-resume-preparation', @@ -21182,6 +21302,8 @@ describe('CodexAgent resume preparation', () => { })).rejects.toThrow('rollout may still have a live writer'); expect(host.request.mock.calls.filter(([method]) => method === Method.ThreadResume)).toHaveLength(0); + expect(host.request.mock.calls.filter(([method]) => method === Method.ThreadStart)).toHaveLength(0); + expect(prepareCodexResumeSession).toHaveBeenCalledOnce(); expect(( agent as unknown as { hostSessionBindingLeases: Map } ).hostSessionBindingLeases.size).toBe(0); diff --git a/packages/maker-core/src/agents/codex/index.ts b/packages/maker-core/src/agents/codex/index.ts index ce53a90a31e..f240bd79208 100644 --- a/packages/maker-core/src/agents/codex/index.ts +++ b/packages/maker-core/src/agents/codex/index.ts @@ -1039,6 +1039,21 @@ function isExpectedTurnIdMismatchError(error: unknown): boolean { return code === -32600 && /expected active turn id\b[\s\S]*\bbut found\b/i.test(message); } +/** + * Only the app-server's canonical missing-rollout response permits replacing + * a thread. Keep this fail-closed: a wrapped message, a different JSON-RPC + * code, or populated error data may describe a different resume failure. + */ +export function isExactNoRolloutThreadResumeError(error: unknown, threadId: string): boolean { + if (typeof error !== 'object' || error === null) return false; + const candidate = error as { code?: unknown; data?: unknown; message?: unknown }; + if (candidate.code !== -32600 || !Object.hasOwn(error, 'data') || candidate.data !== undefined) { + return false; + } + return candidate.message === + `codex app-server thread/resume error -32600: no rollout found for thread id ${threadId}`; +} + // 插话 (steer) 时 turn/steer RPC 的 ack 有界等待上限。AppServerClient.request // 本身没有超时, app-server 卡死时裸 await 会永久挂起 → coordinator steering marker // 永久残留 → 后续插话点击被静默吞掉。正常情况下 ack 是毫秒级, 10s 足够宽裕。 @@ -5550,10 +5565,62 @@ export class CodexAgent extends BaseAgent { userPrompt: reviewMode ? undefined : opts.userPrompt, }); const useProxyChannel = isCodexProxyChannelReady(); - let threadId: string; + let threadId!: string; let codexThreadModelProviderId: string | undefined; let codexProductPromptDelivery: AgentSessionHandle['codexProductPromptDelivery']; + /** + * Start a replacement thread after the exact provider proof that the + * persisted thread has no rollout. This is intentionally narrower than a + * generic resume retry: historical threads must continue to resume, while + * a thread/start id created before its first accepted turn is not resumable. + */ + const startFreshThread = async (): Promise => { + const params: ThreadStartParams = { + cwd: opts.workingDir, + ...currentThreadWorkspaceConfig(), + ...(sessionDynamicTools.length > 0 ? { dynamicTools: sessionDynamicTools } : {}), + ...(threadModelProvider ? { modelProvider: threadModelProvider } : {}), + ...(mutableModel && mutableModel !== 'gpt-5' ? { model: mutableModel } : {}), + ...(mutableServiceTier !== undefined ? { serviceTier: mutableServiceTier } : {}), + ...(developerInstructions && !useProxyChannel ? { developerInstructions } : {}), + }; + acquireHostBindingLeaseIfNeeded(); + assertCurrentHost('thread/start'); + const resp = await host.request(Method.ThreadStart, params, { + timeoutMs: CRITICAL_THREAD_RPC_TIMEOUT_MS, + }); + assertCurrentHost('thread/start'); + if (Object.hasOwn(resp, 'serviceTier')) { + mutableServiceTier = normalizeServiceTier(resp.serviceTier) ?? null; + } + if (mutableModel === 'gpt-5' && resp.model) { + mutableModel = resp.model; + mutableCatalogModel = resp.model; + } + threadId = resp.thread.id; + codexThreadModelProviderId = resp.modelProvider?.trim() || undefined; + refreshCodexAutoReviewerRoute(threadId); + if (hostUsesCodexProxy) { + registerCodexDeveloperInstructions(threadId, developerInstructions); + } + if (useProxyChannel) { + codexProductPromptDelivery = { threadId, historyHasProductPrompt: false }; + } else if (developerInstructions) { + codexProductPromptDelivery = { threadId, historyHasProductPrompt: true }; + } + sdkSessionId = threadId; + readonlyReferencesProfileActive = shouldUseReadonlyReferencesProfile(); + readonlyReferencesProfileFingerprint = currentReadonlyReferencesProfileFingerprint(); + threadMayHaveRollout = false; + log.info('thread/start ok', { + threadId, + model: resp.model, + modelProvider: codexThreadModelProviderId ?? null, + serviceTier: mutableServiceTier ?? null, + }); + }; + /** * 会话中途把单个设置 (serviceTier / model / effort) 立即推给 app-server, * 写入后续 turn 的 sticky context — 不必等下一个 turn/start 携带 (与官方 @@ -5595,7 +5662,8 @@ export class CodexAgent extends BaseAgent { return run; }; if (opts.resumeSessionId && isLikelyValidThreadId(opts.resumeSessionId)) { - // Phase 3: thread/resume 真接通, 不再 fallback 到 thread/start + // Phase 3: thread/resume is the historical-session path. Only the exact + // provider "no rollout found" response below may fall back to thread/start. if (this.deps.prepareCodexResumeSession && !opts.remoteHostId) { try { await this.deps.prepareCodexResumeSession(opts.resumeSessionId); @@ -5677,16 +5745,31 @@ export class CodexAgent extends BaseAgent { serviceTier: mutableServiceTier ?? null, }); } catch (e) { - releaseHostBindingLeaseIfNeeded(); - log.error('thread/resume failed', { error: String(e), resumeSessionId: opts.resumeSessionId }); - const message = `Failed to resume Codex thread: ${String(e)}`; - eventQueue.push({ - type: 'error', - data: { message, isTerminal: true }, - source: 'codex', - }); - eventQueue.end(); - throw new Error(message); + let freshThreadStarted = false; + if (isExactNoRolloutThreadResumeError(e, opts.resumeSessionId)) { + // Codex gives an exact, provider-owned proof that this thread has + // never crossed a turn boundary. Only this error may switch the + // continuation from resume to a fresh thread/start. + log.info('thread/resume reported no rollout; starting a fresh thread'); + try { + await startFreshThread(); + freshThreadStarted = true; + } catch (freshStartError) { + e = freshStartError; + } + } + if (!freshThreadStarted) { + releaseHostBindingLeaseIfNeeded(); + log.error('thread/resume failed', { error: String(e), resumeSessionId: opts.resumeSessionId }); + const message = `Failed to resume Codex thread: ${String(e)}`; + eventQueue.push({ + type: 'error', + data: { message, isTerminal: true }, + source: 'codex', + }); + eventQueue.end(); + throw new Error(message); + } } } else { // developerInstructions 六段拼接 (协议见 thread/start.developerInstructions): @@ -5702,52 +5785,8 @@ export class CodexAgent extends BaseAgent { // 段序语义与 claude-code 对齐(claude 的 [1] 是 SDK 内嵌 preset, 此处无对应段, // 故编号从 [2] 起、共六段)。空段被 .filter 跳过, // 内容为空时不发送 developerInstructions 字段。 - const params: ThreadStartParams = { - cwd: opts.workingDir, - ...currentThreadWorkspaceConfig(), - ...(sessionDynamicTools.length > 0 ? { dynamicTools: sessionDynamicTools } : {}), - ...(threadModelProvider ? { modelProvider: threadModelProvider } : {}), - ...(mutableModel && mutableModel !== 'gpt-5' ? { model: mutableModel } : {}), - ...(mutableServiceTier !== undefined ? { serviceTier: mutableServiceTier } : {}), - ...(developerInstructions && !useProxyChannel ? { developerInstructions } : {}), - }; try { - acquireHostBindingLeaseIfNeeded(); - assertCurrentHost('thread/start'); - const resp = await host.request(Method.ThreadStart, params, { - timeoutMs: CRITICAL_THREAD_RPC_TIMEOUT_MS, - }); - assertCurrentHost('thread/start'); - if (Object.hasOwn(resp, 'serviceTier')) { - mutableServiceTier = normalizeServiceTier(resp.serviceTier) ?? null; - } - if (mutableModel === 'gpt-5' && resp.model) { - mutableModel = resp.model; - // 'gpt-5' 是「用 server 默认」的占位、本身不是目录条目,解析出的真实 id 才是 - // 我们能拿到的最佳目录线索(与下方 wire 规范化不同,后者不更新它)。 - mutableCatalogModel = resp.model; - } - threadId = resp.thread.id; - codexThreadModelProviderId = resp.modelProvider?.trim() || undefined; - refreshCodexAutoReviewerRoute(threadId); - if (hostUsesCodexProxy) { - registerCodexDeveloperInstructions(threadId, developerInstructions); - } - if (useProxyChannel) { - codexProductPromptDelivery = { threadId, historyHasProductPrompt: false }; - } else if (developerInstructions) { - codexProductPromptDelivery = { threadId, historyHasProductPrompt: true }; - } - sdkSessionId = threadId; - readonlyReferencesProfileActive = shouldUseReadonlyReferencesProfile(); - readonlyReferencesProfileFingerprint = currentReadonlyReferencesProfileFingerprint(); - threadMayHaveRollout = false; - log.info('thread/start ok', { - threadId, - model: resp.model, - modelProvider: codexThreadModelProviderId ?? null, - serviceTier: mutableServiceTier ?? null, - }); + await startFreshThread(); } catch (e) { releaseHostBindingLeaseIfNeeded(); log.error('thread/start failed', { error: String(e) }); @@ -5986,7 +6025,7 @@ export class CodexAgent extends BaseAgent { }), }); } catch (e) { - if (!/no rollout found/i.test(String(e))) throw e; + if (!isExactNoRolloutThreadResumeError(e, threadId)) throw e; if (signal?.aborted) throw new Error('Codex send cancelled before acceptance'); threadMayHaveRollout = false; await replaceUnusedThreadWithCurrentProfile(signal); @@ -11293,6 +11332,7 @@ export class CodexAgent extends BaseAgent { get model() { return mutableModel; }, get codexProxyActive() { return hostUsesCodexProxy; }, get codexThreadModelProviderId() { return codexThreadModelProviderId; }, + get codexThreadMayHaveRollout() { return threadMayHaveRollout; }, get codexCindyRemoteCompactionCompatible() { return cindyProviderRemoteCompactionCompatible; }, diff --git a/packages/maker-core/src/session.ts b/packages/maker-core/src/session.ts index 97419df565e..bdfefc63b8b 100644 --- a/packages/maker-core/src/session.ts +++ b/packages/maker-core/src/session.ts @@ -1442,6 +1442,11 @@ export class Session { return this.handle.codexThreadModelProviderId; } + /** Codex-only: exact provider-owned rollout/turn acceptance evidence. */ + get codexThreadMayHaveRollout(): boolean | undefined { + return this.handle.codexThreadMayHaveRollout; + } + /** Codex-only: 当前 host 的独立 Subagent 路由是否兼容 Cindy Codex 远程压缩。 */ get codexCindyRemoteCompactionCompatible(): boolean | undefined { return this.handle.codexCindyRemoteCompactionCompatible;