Skip to content

Commit 608a728

Browse files
committed
fix(mothership): store the recovery authoring marker only while Projects are on
Earlier builds parse recovery config strictly and skip recovery on an unknown key. Only Project authoring reads the marker, so flag-off turns no longer write it and stay recoverable across a rollback.
1 parent b21ab82 commit 608a728

4 files changed

Lines changed: 63 additions & 15 deletions

File tree

‎apps/sim/lib/mothership/chat/post.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
8585
import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1'
8686
import { resolveMothershipModelSettings } from '@/lib/mothership/model-options'
8787
import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/mothership/request/http'
88-
import { RESOURCE_AUTHORING_VERSION } from '@/lib/mothership/request/lifecycle/recovery-config'
88+
import { durableResourceAuthoringVersion } from '@/lib/mothership/request/lifecycle/recovery-config'
8989
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/mothership/request/lifecycle/start'
9090
import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/mothership/request/otel'
9191
import {
@@ -1406,6 +1406,9 @@ export async function handleUnifiedChatPost(req: NextRequest) {
14061406
if (!lease || !sendClaim?.claimToken || sendClaim.storageMethod !== 'database') {
14071407
throw new Error('Chat admission requires its controller and durable send claim')
14081408
}
1409+
const resourceAuthoringVersion = await durableResourceAuthoringVersion(
1410+
executionContext.copilotResourceAdmission !== undefined
1411+
)
14091412
admittedRun = await admitChatTurn.execute({
14101413
principal: {
14111414
kind: 'session',
@@ -1429,7 +1432,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
14291432
},
14301433
recovery: {
14311434
kind: 'interactive_stream',
1432-
resourceAuthoringVersion: RESOURCE_AUTHORING_VERSION,
1435+
resourceAuthoringVersion,
14331436
request: { ...requestPayload, messageId: userMessageId, chatId: actualChatId },
14341437
goRoute: branch.goRoute,
14351438
clientToolPickupExpected,

‎apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { describe, expect, it } from 'vitest'
2-
import { StreamRecoveryConfigSchema } from '@/lib/mothership/request/lifecycle/recovery-config'
1+
import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation'
4+
import {
5+
StreamRecoveryConfigSchema,
6+
streamRecoveryConfig,
7+
} from '@/lib/mothership/request/lifecycle/recovery-config'
8+
9+
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
310

411
const identity = {
512
userId: 'actor',
@@ -84,3 +91,28 @@ it('preserves the explicit Plan wire mode on recovery', () => {
8491
.mode
8592
).toBe('plan')
8693
})
94+
95+
describe('recovery marker written by an admitted turn', () => {
96+
const options = {
97+
interactive: true,
98+
userId: identity.userId,
99+
chatId: identity.chatId,
100+
goRoute: '/api/mothership',
101+
copilotResourceAdmission: createCopilotResourceAdmission({
102+
userId: identity.userId,
103+
invocation: { kind: 'chat', chatId: identity.chatId },
104+
}),
105+
}
106+
107+
it('stays off a recovery config older builds must still parse while Projects are off', async () => {
108+
featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false)
109+
const recovery = await streamRecoveryConfig(options, workspaceRequest)
110+
expect(recovery).not.toHaveProperty('resourceAuthoringVersion')
111+
})
112+
113+
it('restores authoring admission on recovery while Projects are on', async () => {
114+
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects')
115+
const recovery = await streamRecoveryConfig(options, workspaceRequest)
116+
expect(recovery?.resourceAuthoringVersion).toBe(1)
117+
})
118+
})

‎apps/sim/lib/mothership/request/lifecycle/recovery-config.ts‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { hasCopilotResourceAdmission } from '@/lib/mothership/auth/application-d
33
import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants'
44
import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol'
55
import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run'
6+
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
67

78
export const BillingAdmissionSchema = z
89
.object({
@@ -26,6 +27,17 @@ export const DurableChatRequestSchema = ChatPayloadSchema.safeExtend({
2627
*/
2728
export const RESOURCE_AUTHORING_VERSION = 1
2829

30+
/**
31+
* The marker an admitted turn stores for recovery. Builds before it parse recovery strictly and
32+
* would refuse to recover the run, and only Project authoring reads it, so it is written only
33+
* while Projects are on.
34+
*/
35+
export async function durableResourceAuthoringVersion(
36+
admitted: boolean
37+
): Promise<typeof RESOURCE_AUTHORING_VERSION | undefined> {
38+
return admitted && (await isProjectsEnabled()) ? RESOURCE_AUTHORING_VERSION : undefined
39+
}
40+
2941
export const StreamRecoveryConfigSchema = z
3042
.object({
3143
kind: z.literal('interactive_stream'),
@@ -49,26 +61,27 @@ export const StreamRecoveryConfigSchema = z
4961
}
5062
)
5163

52-
export function streamRecoveryConfig(
64+
export async function streamRecoveryConfig(
5365
options: CopilotLifecycleOptions,
5466
request: Record<string, unknown>
5567
) {
5668
if (options.interactive !== true) return undefined
69+
const resourceAuthoringVersion = await durableResourceAuthoringVersion(
70+
hasCopilotResourceAdmission({
71+
...options,
72+
...options.executionContext,
73+
copilotToolExecution: true,
74+
copilotResourceAdmission:
75+
options.executionContext?.copilotResourceAdmission ?? options.copilotResourceAdmission,
76+
})
77+
)
5778
return StreamRecoveryConfigSchema.parse({
5879
kind: 'interactive_stream',
5980
request,
6081
goRoute: options.goRoute ?? '/api/copilot',
6182
clientToolPickupExpected: options.clientToolPickupExpected ?? true,
6283
userTimezone: options.executionContext?.userTimezone,
6384
requestMode: options.executionContext?.requestMode,
64-
resourceAuthoringVersion: hasCopilotResourceAdmission({
65-
...options,
66-
...options.executionContext,
67-
copilotToolExecution: true,
68-
copilotResourceAdmission:
69-
options.executionContext?.copilotResourceAdmission ?? options.copilotResourceAdmission,
70-
})
71-
? RESOURCE_AUTHORING_VERSION
72-
: undefined,
85+
...(resourceAuthoringVersion ? { resourceAuthoringVersion } : {}),
7386
})
7487
}

‎apps/sim/lib/mothership/request/lifecycle/start.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
339339
...(params.admittedRun?.requestContext ?? {}),
340340
requestId,
341341
controllerToken: lease?.value,
342-
recovery: streamRecoveryConfig(orchestrateOptions, requestPayload),
342+
recovery: await streamRecoveryConfig(orchestrateOptions, requestPayload),
343343
}
344344
try {
345345
if (chatId && !orchestrateOptions.recovery) {

0 commit comments

Comments
 (0)